1. Wiped-node back-fill (live gate check 4). Fixed upstream in ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly off: the oplog cap was not the gate. Snapshot detection measured the peer's gap against the oldest surviving oplog row and read an empty oplog as "no gap possible" — and an empty oplog is the steady state of a converged pair, since ack-pruning deletes everything the peer confirmed. The healthy state was the one state that could not heal a wiped peer. Now measured against last_acked_seq when the oplog is empty. Pinned at this level too, not just the library's: the pair harness grows a WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node id and a zero watermark), and the new scenario asserts the emptied oplog as its precondition before wiping, then requires the deployment artifact to come back byte-identical with no new deploy. Verified RED against the pinned 0.1.1 (times out waiting for back-fill) and green on 0.1.2. 2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source: StoreAsync now skips entirely when the pointer already names this deployment/revision, the SHA matches the bytes, and the expected chunk count is present. Re-caching an artifact the node already holds — every restart's boot-from-cache, every RestoreApplied — writes nothing and mints no oplog rows, where before it cost a delete plus an insert per chunk plus a pointer update, all identical to what was already there. Identity is over the bytes and the chunks are counted, both deliberately: a re-composed artifact can carry the same ids with different bytes, and a pointer can name a deployment whose chunks are missing, where skipping would make an unreadable cache permanent. Both guards verified RED against a naive pointer-only skip. The gate's stated bound was also wrong and is corrected in the doc: growth was never headed for the 1M row cap. AddZbLocalDbReplication is registered unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes and the 7-day MaxOplogAge cap prunes. Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a clean master too) and a flaky Roslyn race test (green 2/2 in isolation). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
7.9 KiB
LocalDb pair replication — operations runbook
Phase 1 scope. Every driver-role OtOpcUa node keeps a consolidated
ZB.MOM.WW.LocalDbSQLite database. Today it caches exactly one thing: the deployed-configuration artifact, chunked, so a node can boot from cache when central SQL Server is unreachable. That cache can optionally replicate to the node's redundant pair peer over the library's gRPC sync. Replication is default-OFF and fail-closed. This runbook covers enabling it, the operational rules that keep a pair converging, and the DB-inspection safety rules.
What replicates, and what it buys you
- Replicated tables:
deployment_artifacts(the artifact, split into base64 chunks) anddeployment_pointer(one current-deployment pointer per cluster). Registered — in this order, after the DDL — byLocalDbSetup.OnReady. - The payoff: in a redundant driver pair, either node can be the one that applied the latest deploy and wrote the cache. With replication on, the peer holds a byte-identical copy. So a node that restarts into a central-SQL outage can boot the current config even if it never applied that deploy itself — its peer did, and the row replicated.
- Convergence model: last-writer-wins per primary key, HLC-stamped. Both nodes also store locally after every apply, so the pair converges to identical content regardless of which node deployed. Retention (newest-2 deployments per cluster) prunes on one node and the deletes replicate as tombstones.
Enabling replication on a pair
Replication has two knobs, both under LocalDb:
| Key | Role | Notes |
|---|---|---|
LocalDb:SyncListenPort |
binds the dedicated h2c sync listener | 0 (default) = no listener, replication off. Set the same non-zero port on both nodes of the pair. |
LocalDb:Replication:PeerAddress |
the address this node dials | Set on one node (the initiator); leave empty on the other (passive). The stream is bidirectional — one dial carries both directions. |
LocalDb:Replication:ApiKey |
bearer token for the sync stream | Must be byte-identical on both nodes. See fail-closed rule below. Supply via ${secret:...} / env — never a cleartext literal in production config. |
LocalDb:Replication:MaxBatchSize |
rows per replication batch | 16 for the artifact cache. Batching is row-count-only against gRPC's 4 MB message cap; artifact chunk rows are ≈171 KB, so 16 × 171 KB ≈ 2.7 MB stays under the cap. Raising it risks tripping the cap. |
Example (site-a-1 initiator, site-a-2 passive):
site-a-1: LocalDb__SyncListenPort=9001
LocalDb__Replication__PeerAddress=http://site-a-2:9001
LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key}
LocalDb__Replication__MaxBatchSize=16
site-a-2: LocalDb__SyncListenPort=9001
LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key} # no PeerAddress
LocalDb__Replication__MaxBatchSize=16
Kestrel note. Setting
SyncListenPortmakes the Host add an explicitListen*for the h2c sync listener. An explicitListen*makes Kestrel ignoreASPNETCORE_URLSentirely, so the Host also re-binds the primary HTTP port in the same block. If you change the primary port, verify both:<httpPort>and:<syncPort>appear in the startup "Now listening on" lines.
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:
- No key configured ⇒ every sync call is refused (
PermissionDenied). "No key" is never "no auth required". - A key mismatch ⇒ the pair silently stops converging. The initiator's stream is rejected at the peer; nothing errors loudly. A typo in one node's key looks exactly like "replication is broken". If a pair is not converging, check the keys match first.
Operational rules
-
Stop / start the pair together where you can. Each node keeps working (and caching locally) 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.
-
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 knowing: this needs
ZB.MOM.WW.LocalDb≥ 0.1.2 (on0.1.1a converged pair has pruned every oplog row on ack, and the wiped node was never snapshotted — it stayed empty until the next deploy), and it heals the cache, so the node regains boot-from-cache for future outages. A node wiped during a central outage is still repopulated by its peer under 0.1.2; a node with no peer replication configured self-heals only from central on its next successful apply. -
What boot-from-cache does NOT cover. The cache is a fallback for central-SQL outages at boot, not a replacement for central. A new deployment still requires central SQL — the cache is only read when the central fetch fails at startup. When central returns, the node resumes fresh-config behavior. Boot-from-cache logs a running-from-cache signal; treat a node that stays on it as a central-connectivity incident, not steady state.
Inspecting the LocalDb file — safety rules
The database runs in WAL mode. These rules exist because violating them corrupted a live DB in the 2026-07-20 ScadaBridge incident:
-
Never run
sqlite3on the live file (host-side, against a bind-mounted or container path). Opening a live WAL DB from a second process across virtiofs poisons the WAL. Always copy the triplet first and query the copy:docker cp <node>:/app/data/otopcua-localdb.db /tmp/localdb.db docker cp <node>:/app/data/otopcua-localdb.db-wal /tmp/localdb.db-wal docker cp <node>:/app/data/otopcua-localdb.db-shm /tmp/localdb.db-shm sqlite3 /tmp/localdb.db 'SELECT cluster_id, deployment_id FROM deployment_pointer;' -
Metrics come from the container, not a host curl (
aspnet:10.0has nocurl):docker run --rm --network container:<node> curlimages/curl:latest -s localhost:<httpPort>/metrics | grep localdb_ -
If an anomaly appears within seconds of your own measurement, suspect the measurement. Restart both nodes and re-observe untouched before blaming the library.
Useful queries (on a copied triplet)
-- What each node thinks the current deployment is
SELECT cluster_id, deployment_id, revision_hash, applied_at_utc FROM deployment_pointer;
-- Convergence check: the pointer's origin stamp should match on both nodes
SELECT pk_json, hlc, node_id, is_tombstone
FROM __localdb_row_version WHERE table_name = 'deployment_pointer' ORDER BY pk_json;
-- Replication backlog (should drain to 0 when a pair is caught up)
SELECT COUNT(*) FROM __localdb_oplog;
See also
docs/Redundancy.md— the pair-local config cache section.docs/Configuration.md— theLocalDbappsettings section.- The two-node convergence harness:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/.