From 12fa7affc3f13332734b5f7e998cda81742ace25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 13:44:53 -0500 Subject: [PATCH] chore(deps): LocalDb 0.3.4 -> 0.3.7 (#130, scadaproj#53) Pins ZB.MOM.WW.LocalDb, .Replication and .Contracts at 0.3.7 (published 2026-11-12). Wire-compatible; no config or code change needed. - 0.3.6 (scadaproj#36): the library sync service enforces LocalDb:Replication:ApiKey itself. LocalDbSyncAuthInterceptor is kept for its refuse-every-stream-when-blank rule; its doc comment is updated. The rig's site-a pair already carries one identical key on both nodes. - 0.3.7 (scadaproj#16): a tombstone is held until the peer has it, so a node offline past TombstoneRetention no longer resurrects deleted rows. The survivor's ledger (sf_messages / site_events deletes) grows for the length of the outage; optional TombstoneHardCap bounds it. Deploy notes added to the topology + installation guides, the S&F requirement and CLAUDE.md: stop/start the pair together, same ApiKey on both nodes, ledger growth while a peer is down. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- CLAUDE.md | 2 +- Directory.Packages.props | 6 ++-- docs/deployment/installation-guide.md | 12 ++++++-- docs/deployment/topology-guide.md | 29 ++++++++++++++----- .../requirements/Component-StoreAndForward.md | 2 +- .../LocalDbSyncAuthInterceptor.cs | 10 ++++--- 6 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cd506dc..76238fcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie - CDC replication does all three jobs now: config deploys reach the standby as ordinary row changes — **the standby makes no fetch at all** during a deploy (`SiteReconciliationActor`'s node-STARTUP fetch when central reports gaps is a different, surviving path) — and buffer mutations replicate via triggers on `sf_messages`. `ReplaceAllAsync` was a destructive delete-all-then-insert-all resync and is **unsafe to reintroduce**: a mass DELETE on a replicated table would be captured and shipped to the peer. LocalDb's snapshot resync merges per row under LWW and never deletes, which is also why the old N1 directional-authority guard is gone — there is no wipe left to gate. - **`notification_lists` and `smtp_configurations` are created but deliberately NOT registered.** They are permanently empty on a site (no writer since 2026-07-10, the migrator skips them, the active-node purge keeps them empty), and registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Pinned by a security-named test, and verified live: those two tables have **no CDC triggers** on either rig node. - - **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. And a node offline longer than `LocalDb:Replication:TombstoneRetention` (default 7 days) can resurrect deleted rows on rejoin. See `docs/deployment/topology-guide.md`. + - **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. **LocalDb 0.3.7 (2026-11-12, scadaproj#53):** a tombstone is pruned only once past `TombstoneRetention` **and** held by the peer, so a node offline past retention no longer resurrects deleted rows — the cost is that the survivor's tombstone ledger (mostly `sf_messages` and `site_events` deletes) grows for the length of the outage; optional `TombstoneHardCap` (default off) bounds it at the price of the old risk. The library's sync endpoint also enforces `LocalDb:Replication:ApiKey` itself since 0.3.6 (scadaproj#36) — set the same key on both nodes; `LocalDbSyncAuthInterceptor` is kept for its stricter refuse-everything-when-blank rule. See `docs/deployment/topology-guide.md`. - **Batching is by BYTE BUDGET as of LocalDb 0.2.0** — `LocalDb:Replication:MaxBatchBytes` (default **2 MB**, sized under the 4 MB gRPC cap) bounds a delta/snapshot message by summed serialized size via a per-message split in `SyncSession.PumpLoopAsync`, with `MaxBatchSize` demoted to a secondary row cap; a single row over budget is sent alone rather than stalling the stream. **The rig's old `MaxBatchSize = 16` pin (a hand-computed byte-budget proxy: ~70 KB worst-case `config_json` x the 500 default is ~35 MB) is retired, but `MaxBatchSize` is NOT fully redundant with `MaxBatchBytes`** — it also bounds the separate DB READ page in `OplogStore.ReadBatchAboveAsync`/`SnapshotStreamer`, which materializes the whole page into memory *before* the byte-budget split runs, so an unset (500-default) `MaxBatchSize` still lets a reconnect drain transiently allocate ~35 MB per read even though every wire message stays under `MaxBatchBytes` (arch-review adversarial finding F2). Both site-a nodes on `docker/` therefore pin an explicit `"MaxBatchSize": 64` to bound that transient allocation, while `MaxBatchBytes` stays unset (its 2 MB default) to bound the wire message; site-b/site-c stay unreplicated so the key doesn't apply there. - **CDC registration is conditional, and both directions self-heal at boot** (arch-review WP1.3 + WP3.3, `Host/SiteLocalDbSetup.cs`). Capture triggers are installed only when this node has replication configured — `PeerAddress` **or** `ApiKey`, an OR because only the dialling half sets `PeerAddress` while the passive half carries the key alone. An unreplicated node calls **`DeregisterReplicated`** on all twelve tables at boot, dropping triggers an earlier build left behind and pruning their oplog/row-version rows (idempotent; logs once at Information when something was actually cleaned). A replicating node registers with **`baselineExistingRows: true`**, which seeds `__localdb_row_version` for pre-existing rows at the LWW floor (HLC `0`, this node's id) and flags a snapshot resync — so turning replication ON for a site that has been running without it now converges on the rows already in the file instead of only on writes made after the restart. Deregistration must be symmetric (the handshake compares registered-table digests fail-closed), so replication is a both-nodes-together change in either direction. LocalDb 0.2.0 also makes backlog depth O(1) and drops the unused `__localdb_oplog_hlc` index; the on-disk bookkeeping schema goes to v2, upgraded in place on open, with no wire change (0.1.x peers still sync). - **LocalDb 0.3.0 (2026-08-15) makes that snapshot flag PER-TABLE** — the residual 0.2.1 explicitly deferred. On-disk bookkeeping schema goes to **v3** (a new `__localdb_snapshot_state` table, upgraded in place on open); still wire-compatible, and `lib_schema_version` deliberately stays `1` because the handshake compares it fail-closed for *equality*. The extension is negotiated by **capability** (`Handshake.supports_partial_snapshot`, `SnapshotBegin.tables` where **empty == all tables**), so a peer that does not advertise it is served a full snapshot — a scoped one would make it advance `last_applied_remote_seq` past deltas it never received. For the same reason **a partial snapshot moves no watermark on either side**; the overlapping deltas are still pumped and absorbed by LWW. Snapshot debt now clears on the receiver's new `SnapshotAck` rather than on enqueue, so a crash mid-snapshot re-streams the same scope. **`SiteLocalDbSetup.cs` needed no restructuring** — it already calls `RegisterReplicated` once per table in a loop, which is exactly what per-table flagging keys off. The consequence to know: **adding an eleventh table to `ReplicatedTables` on an already-replicating site now snapshots that one table**, where through 0.2.x the same edit re-streamed all eleven in full in both directions. First-boot behaviour is unchanged (all ten seed at once ⇒ the flagged set is every registered table ⇒ an ordinary full snapshot), and so is upgrading the rig in place (the ten tables are already ledgered, nothing seeds, nothing is flagged). A future "register the whole list in one call" API would silently give the coarse behaviour back. diff --git a/Directory.Packages.props b/Directory.Packages.props index 49f7f2ed..73a777f8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -119,9 +119,9 @@ - - - + + +