chore(deps): LocalDb 0.3.4 -> 0.3.7 (#889, scadaproj#53)
v2-ci / unit-tests (push) Waiting to run

Pins ZB.MOM.WW.LocalDb and .Replication at 0.3.7 (published 2026-11-12).
Wire-compatible; no 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; doc comment updated. docker-dev
  site-a pair: both nodes now read one variable,
  OTOPCUA_SITE_A_LOCALDB_SYNC_KEY (same dev default as before), so an
  override can never split the pair.
- 0.3.7 (scadaproj#16): a tombstone is held until the peer has it, so a
  node offline past TombstoneRetention no longer brings a pruned
  deployment back. The ledger grows while a peer is down; optional
  TombstoneHardCap (documented in Configuration.md) bounds it.

Runbook, Configuration.md and the cluster-topology rule updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 13:44:55 -05:00
parent 2e9cccbdef
commit e303165444
6 changed files with 40 additions and 23 deletions
+7 -3
View File
@@ -309,8 +309,12 @@ same event accepted on both nodes — which happens in every boot window, since
docker-dev rig the site-a pair and site-b both run `AlarmHistorian__Enabled=true` against a
deliberately unresolvable gateway endpoint, so the buffer is always a live, non-draining queue.
**The library pin is `0.3.4`** (`Directory.Packages.props`, both `ZB.MOM.WW.LocalDb` and
`.Replication`), bumped from `0.2.1` on 2026-09-03 (was itself bumped from `0.1.3` on 2026-08-15).
**The library pin is `0.3.7`** (`Directory.Packages.props`, both `ZB.MOM.WW.LocalDb` and
`.Replication`; bumped from `0.3.4` on 2026-11-12, scadaproj#53 — the library's sync service now
enforces `LocalDb:Replication:ApiKey` itself (0.3.6, scadaproj#36; `LocalDbSyncAuthInterceptor` kept
for its refuse-when-blank rule), `RegisterReplicated` refuses `AUTOINCREMENT` keys (none here), and
a tombstone is held until the peer has it (0.3.7, scadaproj#16 — no resurrection after a long outage,
ledger grows while the peer is down; optional `TombstoneHardCap`)). Earlier: bumped from `0.2.1` on 2026-09-03 (was itself bumped from `0.1.3` on 2026-08-15).
The 0.2.1→0.3.4 bump is wire-compatible with 0.2.x, and the on-disk bookkeeping schema v2 → v3
upgrades in place on open, so no rig or volume action was needed. `RegisterReplicated(string)` is
unchanged and everything 0.2.x/0.3.x added is additive, **except** that `RegisterReplicated` (0.3.3)
@@ -358,7 +362,7 @@ things are worth knowing:
used to replicate only writes made after the restart. It seeds the ledger at the LWW floor
(HLC `0`, this node's id) and flags a resync — a baselined row loses to any genuine remote
write of the same key and wins only where the peer has no version at all. It is idempotent, so
it is free on every boot after the first. As of `0.3.0` (this repo pins `0.3.4`) the snapshot
it is free on every boot after the first. As of `0.3.0` (this repo pins `0.3.7`) the snapshot
flag is **per-table**, not per-database — baselining one table no longer streams every
registered table in both directions. (Prior to `0.3.0`, on the `0.2.1` pin this repo carried
until 2026-09-03, the flag was per-database and baselining anything streamed *every*
+2 -2
View File
@@ -224,8 +224,8 @@
package floors the native SQLitePCLRaw.lib.e_sqlite3 at 2.1.12, so consumers inherit the
CVE-2025-6965-fixed native binary without needing the surgical pin below.
-->
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.3.4" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.3.4" />
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.3.7" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.3.7" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
+4 -2
View File
@@ -729,7 +729,9 @@ services:
# DEV-ONLY committed key (rig philosophy — like the SQL password + secrets KEK above). It
# MUST be byte-identical on both nodes: the interceptor is fail-closed, so any mismatch
# silently stops the pair converging. NEVER reuse this key outside this local dev rig.
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
# Both site-a nodes read the SAME variable, so an override can never split the pair; since
# LocalDb 0.3.6 the library's sync service checks this key too (scadaproj#36).
LocalDb__Replication__ApiKey: "${OTOPCUA_SITE_A_LOCALDB_SYNC_KEY:-dev-site-a-localdb-sync-key}"
# Since LocalDb 0.2.0 the WIRE message is bounded by MaxBatchBytes (2 MB default, left
# unset), not by this. MaxBatchSize is now the secondary row cap AND the DB read-page size,
# and the read page is materialised in memory before the byte-budget split runs — so 16 is
@@ -838,7 +840,7 @@ services:
ServerHistorian__UseTls: "false"
LocalDb__Path: "/app/data/otopcua-localdb.db"
LocalDb__SyncListenPort: "9001"
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
LocalDb__Replication__ApiKey: "${OTOPCUA_SITE_A_LOCALDB_SYNC_KEY:-dev-site-a-localdb-sync-key}"
LocalDb__Replication__MaxBatchSize: "16"
ports:
- "4843:4840"
+3 -2
View File
@@ -494,7 +494,8 @@ replication stays inert until the sync port + peer are set.
| `LocalDb:Path` | `./data/otopcua-localdb.db` | SQLite file path. `ValidateOnStart`-required once `AddZbLocalDb` runs (driver nodes only). In containers put it on a durable volume. |
| `LocalDb:SyncListenPort` | `0` | `0` = replication off (no sync listener). A non-zero port binds a dedicated **h2c** sync listener; set the **same** port on both pair nodes. Setting it makes the Host add an explicit Kestrel `Listen*` and re-bind the primary HTTP port (an explicit `Listen*` otherwise discards `ASPNETCORE_URLS`). |
| `LocalDb:Replication:PeerAddress` | `""` | The peer this node **dials** (`http://<peer>:<port>`). Set on the initiator only; leave empty on the passive node. The stream is bidirectional. |
| `LocalDb:Replication:ApiKey` | `""` | Bearer token for the sync stream. **Fail-closed:** no key ⇒ the passive endpoint refuses everything; a mismatch ⇒ the pair silently stops converging. **Must be byte-identical on both nodes.** Supply via `${secret:...}` / env — never a cleartext literal in production. |
| `LocalDb:Replication:ApiKey` | `""` | Bearer token for the sync stream. **Fail-closed:** no key ⇒ the passive endpoint refuses everything; a mismatch ⇒ the pair silently stops converging. **Must be byte-identical on both nodes.** Enforced by the host interceptor and, since LocalDb 0.3.6, by the library's sync service too. Supply via `${secret:...}` / env — never a cleartext literal in production. |
| `LocalDb:Replication:TombstoneHardCap` | unset (off) | LocalDb 0.3.7. A tombstone is kept until it is past `TombstoneRetention` (7 days) **and** the peer holds it, so the ledger grows while a peer is down. This optional cap (≥ `TombstoneRetention`) bounds an undelivered tombstone's life; past it a returning node can resurrect deleted rows again. Leave unset unless disk is the constraint. |
| `LocalDb:Replication:MaxBatchSize` | `500` (library) | **Secondary** row-count cap on a batch — *and* the DB read-page size. `16` on the replicating pair. Since LocalDb 0.2.0 the wire message is bounded by `MaxBatchBytes`, not by this, so it is no longer the hand-tuned byte proxy the 4 MB cap once forced. It still matters because `OplogStore.ReadBatchAboveAsync`/`SnapshotStreamer` materialise a whole read page **before** the byte-budget split runs — `16` bounds that transient allocation at ≈2.7 MB for ≈171 KB artifact chunk rows. |
| `LocalDb:Replication:MaxBatchBytes` | `2 MB` (library) | **Primary** cap on one outbound delta/snapshot message: the summed serialized size of its entries, sized under gRPC's 4 MB receive limit. Left unset across the fleet. A single row larger than the budget is sent **alone** rather than stalling the stream. Added in LocalDb 0.2.0; needs no wire change, so a node on this version interoperates with any earlier peer in both directions. |
@@ -510,7 +511,7 @@ turning replication on converges the rows **already in the file** and not merely
unconfigured node **deregisters** them at boot, dropping capture triggers an earlier build installed. ⚠️ The
sync handshake compares registered-table digests **fail-closed**, so flipping either key is a
both-nodes-together change: set it on both and restart both. As of LocalDb 0.3.0 (this repo pins
0.3.4) baselining flags a **per-table** snapshot resync — enabling replication on one table no
0.3.7) baselining flags a **per-table** snapshot resync — enabling replication on one table no
longer forces a full-database resync of every registered table in both directions.
Replication is **default-OFF** across the fleet; it is enabled per-pair as an opt-in. See
@@ -77,9 +77,10 @@ Replication has two knobs, both under `LocalDb`:
| `LocalDb:Replication:MaxBatchSize` | secondary row cap **and** the DB read-page size | `16` for the artifact cache. **Since LocalDb 0.2.0 this no longer bounds the wire message** — `MaxBatchBytes` does — so it is not the byte proxy the note here used to describe. Keep it at `16` anyway: `OplogStore`/`SnapshotStreamer` materialise a whole read page in memory *before* the byte-budget split runs, and 16 × ≈171 KB artifact chunk rows ≈ 2.7 MB bounds that transient allocation. Raising it no longer risks tripping the gRPC cap, only the allocation. |
| `LocalDb:Replication:MaxBatchBytes` | primary cap on one outbound message (bytes) | Library default **2 MB**, sized under gRPC's 4 MB receive limit. Left unset on this rig. A single row over budget is sent alone rather than stalling the stream. |
> **Library version.** This repo pins **`ZB.MOM.WW.LocalDb` / `.Replication` 0.3.4**
> (`Directory.Packages.props`; bumped from `0.2.1` on 2026-09-03, itself bumped from `0.1.3` on
> 2026-08-15). Every move in that chain needed no config or code change here: the wire protocol is
> **Library version.** This repo pins **`ZB.MOM.WW.LocalDb` / `.Replication` 0.3.7**
> (`Directory.Packages.props`; bumped from `0.3.4` on 2026-11-12 under scadaproj#53, from `0.2.1`
> on 2026-09-03, and from `0.1.3` on 2026-08-15). 0.3.5–0.3.7 are wire-compatible too; what they
> change for an operator is the ApiKey rule and the tombstone rule below. Every move in that chain needed no config or code change here: the wire protocol is
> unchanged end to end (0.1.x/0.2.x/0.3.x peers all still sync), and the on-disk bookkeeping schema
> upgrades in place on open (now v3) with no operator action on an existing node's database file.
> Behaviours worth knowing at the current pin: batching is byte-budgeted (the two rows above);
@@ -133,8 +134,10 @@ site-a-2: LocalDb__SyncListenPort=9001
### The ApiKey rule (fail-closed)
The replication library's passive endpoint verifies **no** authentication — the host
`LocalDbSyncAuthInterceptor` is the only gate, and it is **fail-closed**:
Two gates check the same key. Since LocalDb 0.3.6 (scadaproj#36) the library's passive
`LocalDbSyncService` enforces `LocalDb:Replication:ApiKey` itself (Bearer, constant-time,
`Unauthenticated` on a mismatch). The host `LocalDbSyncAuthInterceptor` runs first and is kept for
its stricter rule — it is **fail-closed**:
- **No key configured ⇒ every sync call is refused** (`PermissionDenied`). "No key" is never
"no auth required".
@@ -148,11 +151,16 @@ The replication library's passive endpoint verifies **no** authentication — th
while its peer is down; the outage is not a data-loss event — the surviving node accumulates
writes and the peer catches up on rejoin. But a long-lived solo node drifts further from its
peer, so avoid leaving a pair split for extended periods.
- **Tombstone-retention resurrection window.** Retention prunes to the newest 2 deployments and
replicates the prune as tombstones. Tombstones are themselves retained only for a bounded window.
If a node is offline **longer than the tombstone-retention window**, a delete that happened during
its outage may no longer be expressible as a tombstone on rejoin — a pruned deployment could
briefly reappear until the next deploy re-prunes it. Keep pair outages well inside that window.
- **Tombstones are held until the peer has them (LocalDb 0.3.7, scadaproj#16).** Retention prunes
to the newest 2 deployments and replicates the prune as tombstones. Through 0.3.6 a tombstone was
dropped after `LocalDb:Replication:TombstoneRetention` (7 days) whether or not the peer had it, so
a node offline longer than that could bring a pruned deployment back on rejoin. As of 0.3.7 a
tombstone is pruned only when it is past retention **and** delivered (retention restarts at
delivery), so a rejoin after any length of outage is safe. **The cost is ledger growth while the
peer is down:** the survivor keeps every undelivered tombstone (deployment prunes, drained
`alarm_sf_events` rows) until the partner returns. Optional `LocalDb:Replication:TombstoneHardCap`
(default unset = off, must be ≥ `TombstoneRetention`) bounds it, and past the cap the old risk
returns. A node restored from an old backup of its own file is still not covered.
- **A rebuilt node is back-filled by its peer.** If a node loses its LocalDb file — a wiped volume,
a re-imaged host, a fresh container — it rejoins with an empty database and its peer snapshots the
cached configuration back to it, with no new deploy and no central SQL. Two things are worth
@@ -9,10 +9,12 @@ using ZB.MOM.WW.LocalDb.Replication;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Gates the LocalDb passive sync endpoint. The replication library deliberately leaves inbound
/// authentication to the host — its <c>LocalDbSyncService</c> verifies nothing — so without this
/// interceptor anything that can reach a driver node's sync port could stream arbitrary rows
/// into that node's deployment-artifact cache.
/// Gates the LocalDb passive sync endpoint. Through LocalDb 0.3.5 the replication library left
/// inbound authentication to the host — its <c>LocalDbSyncService</c> verified nothing — so
/// without this interceptor anything that could reach a driver node's sync port could stream
/// arbitrary rows into that node's deployment-artifact cache. Since 0.3.6 (scadaproj#36) the
/// library checks the same key and header itself; this interceptor is kept for the one rule the
/// library does not apply — refusing every stream when no key is configured.
/// </summary>
/// <remarks>
/// <para>