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
10 KiB
LocalDb Phase 1 — live gate (docker-dev rig)
Task 16 of
2026-07-20-localdb-adoption-phase1.md. Evidence log for the 8 live-rig checks. Rig: localdocker-dev/docker-compose.yml(6 host nodes, one shared SQL). site-a pair replicates; central + site-b are default-OFF.Safety rules followed: all DB inspection via
docker cpof thedb/-wal/-shmtriplet out of the container, querying the copy — never hostsqlite3on the live WAL file. Metrics via acurlimages/curlsidecar with--network container:<node>(aspnet:10.0has no curl).
Rig build/up
Two real defects surfaced by the rebuild (both fixed on-branch, re-verified):
NU1101— packageSourceMapping gap (4b2f0e6e).ZB.MOM.WW.LocalDb*was not mapped to thedohertj2-giteafeed, so any clean restore (Docker image build, CI, fresh clone) failed. Local dev builds masked it via the warm global NuGet cache. Fixed by adding the two patterns; verified with a temp-packages-dir restore.ASPNETCORE_HTTP_PORTSre-bind gap (ce9fa07f). The Kestrel re-bind read onlyurls/ASPNETCORE_URLS; the aspnet:8.0+ base image setsASPNETCORE_HTTP_PORTS=8080(all interfaces) as the container default instead. A driver node that never setsURLSfell through tolocalhost:5000, silently moving its health/metrics surface to loopback. Central nodes were fine (they setASPNETCORE_URLS=9000explicitly). Fixed to fall through URLS → HTTP_PORTS/HTTPS_PORTS → 5000; 4 new unit tests. After the fix, driver nodes bind[::]:8080+[::]:9001(site-a) /[::]:8080only (site-b).
Rig: 6 nodes rebuilt from branch feat/localdb-phase1 @ ce9fa07f, all up.
Checks
| # | Check | Result | Evidence |
|---|---|---|---|
| 1 | Rig up, site-a pair healthy, /metrics shows localdb_ series |
✅ PASS | Both site-a nodes /healthz 200 Healthy; /metrics shows localdb_oplog_depth + localdb_sync_reconnects_total on meter ZB.MOM.WW.LocalDb.Replication (allowlist works); site-a-2 served /localdb_sync.v1.LocalDbSync/Sync with routing match_status=success (interceptor passed, session established); both oplogs depth 0 |
| 2 | Deploy → both site-a nodes' pointer + artifacts byte-identical, same HLC + origin | ✅ PASS | Deploy 6e687451… (rev efb04c79…) via POST :9200/api/deployments → both site-a-1 and site-a-2 deployment_pointer = SITE-A / 6e687451… / efb04c79… / sha EFB04C79… (identical); artifact 1 chunk each; pointer __localdb_row_version identical on both: hlc 116955620225646592, origin node_id a0576df6-…, tombstone 0 — one row replicated, not two derivations; both oplogs depth 0 |
| 3 | Boot-from-cache: SQL down, restart site-a-2, serves cached config w/ signal | ✅ PASS (found + fixed a defect) | With SQL stopped, site-a-2 restart logged RUNNING FROM CACHE — … booted deployment 6e687451…; address space materialised from the cached blob: AddressSpaceApplier: applied plan (added=18) / OpcUaPublish: applied rebuild (added=18). Browse confirms 16 nodes on site-a-2 = 16 on site-a-1. Defect the gate caught (9137cb41): pre-fix the rebuild re-read the artifact from the down ConfigDb and no-op'd, so the cache-booted node served 1 node vs the healthy peer's 16 — it logged success but browsed empty. Fixed by passing the in-hand cached blob to RebuildAddressSpace; regression test added |
| 4 | Replicated-cache payoff: wipe a-2 DB, repopulate, then SQL-down boot | ⚠️ PARTIAL — 1 defect fixed; the limitation is since CLOSED (LocalDb 0.1.2 — see Summary) | Wiped a-2's LocalDb volume, restarted with SQL down: sync session re-established (a-2 served a Sync call) but a-2's cache stayed 0/0/0 and a-1's oplog was 0 — the deploy's rows had been acked by the old a-2 and pruned, so there was no delta to send and no snapshot-resync fired. ⇒ Limitation: pure replication does not back-fill a fully-wiped, already-converged node (library delta-replication; snapshot-resync is gated on the oplog cap, which the running engine never hits). Separately found a defect (c6a9f93a): recovery via RestoreApplied never wrote the cache (only fresh ApplyAndAck did), so a wiped node stayed cache-less. Fixed: RestoreApplied now re-caches. Verified — after the fix, wiped a-2 restarted with SQL up: restored served state … on bootstrap → cache repopulated SITE-A / 6e687451, chunks=1. Net: a wiped node self-heals its cache from central on next boot (regaining boot-from-cache for future outages); it is not back-filled by its peer during the same outage. |
| 5 | Transport kill: pause a-2, oplog rises; unpause, drains to 0 | ✅ PASS | docker pause on a-2 severed the sync transport; during the window a-2 accumulated oplog=2 unacked local writes (its re-cache) while a-1 held 0. docker unpause → a-2's oplog drained to 0 within 5s, both converged on the same pointer. Note: the rig's redeploys carry an identical revision, so a fresh cache write on a-1 short-circuits (no-op) — the store-during-partition variant is covered by the automated harness scenario WritesWhileTransportDown_SurviveRejoin (proven red without RegisterReplicated) |
| 6 | Retention: 3rd deploy prunes oldest; chunks gone + tombstones on BOTH nodes | ✅ PASS | Cached 3 distinct deployment-ids (via deploy + restart re-cache). Both nodes retain exactly the newest 2 (475df87a, 962962dc); the oldest 6e687451's chunks are 0 on both (pruned); 1 artifact tombstone on both — the prune replicated as a tombstone, not left as a live chunk |
| 7 | Both-nodes-together restart: clean rejoin, identical counts, no I/O errors | ✅ PASS | Restarted site-a-1 + site-a-2 together: 0 disk I/O error/SQLITE_IOERR/corrupt lines in either log; both re-cached and converged to identical counts (ptr 962962dc…, chunks=2, rowver=3). Byte-identical convergence preserved across a simultaneous restart |
| 8 | site-b default-OFF pin: cache works locally, Healthy, no sync listener on 9001 | ✅ PASS (+1 finding, since CLOSED by the skip-if-unchanged cache write — see Summary) | site-b-1 cache works locally (SITE-B / 6e687451, chunks=1, from its own apply); listeners [::]:8080 only — no 9001; port 9001 not bound (/proc/net/tcp); /healthz 200; no sync/reconnect metric series. Finding (follow-up, not a blocker): localdb_oplog_depth=5 — because OnReady registers replication unconditionally, a default-OFF node captures cache writes into its oplog but has no peer to ack/prune them, so the oplog grows slowly (≈2 rows/deploy, amplified by the re-cache-on-restore fix). Bounded by the library MaxOplogRows cap (1M); worth a periodic pruner or skip-if-unchanged in a follow-up |
Summary
8/8 checks pass (check 4 with a documented limitation). The gate exercised the real image built
from the branch, the real interceptor over a real loopback+cross-container h2c transport, and real
docker cp-triplet DB inspection — and caught four real defects that every offline test had
missed, each now fixed on-branch with a regression test:
NU1101packageSourceMapping gap (4b2f0e6e) —ZB.MOM.WW.LocalDb*unmapped to the gitea feed; every clean restore (Docker, CI, fresh clone) failed. Masked locally by the warm NuGet cache.ASPNETCORE_HTTP_PORTSre-bind gap (ce9fa07f) — the sync-listener re-bind ignored the aspnet:8.0+ container-default port var, silently moving driver health/metrics to loopback:5000.- Empty address space on boot-from-cache (
9137cb41) — the rebuild re-read the artifact from the down ConfigDb and no-op'd, so a cache-booted node logged success but served clients 1 node vs the healthy peer's 16. The single most important find: boot-from-cache was half-working. - Cache not repopulated on
RestoreApplied(c6a9f93a) — a wiped/fresh-volume node recovered its served state from central but stayed cache-less, unable to boot-from-cache on the next outage.
Two documented limitations (follow-ups, not blockers) — both since CLOSED:
- No replication back-fill of a fully-wiped node (check 4): a peer's already-acked cache rows are
pruned from its oplog and snapshot-resync is gated on the (never-hit) oplog cap, so a wiped node is
not healed by its peer — it self-heals from central on next boot instead.
→ FIXED in
ZB.MOM.WW.LocalDb0.1.2 (scadaprojcad3bcb). The diagnosis in this doc was slightly off: the cap was not the gate.ComputeSnapshotRequiredAsyncmeasured 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 has confirmed. So the healthy state was the one state that could not heal a wiped peer. With an empty oplog the gap is now measured againstlast_acked_seq. Covered at both levels:SnapshotResyncTests.WipedPeer_AfterEverythingWasAckedAndPruned_StillGetsSnapshotin the library, andLocalDbPairConvergenceTests.WipedNode_IsBackFilledByItsPeer_WithoutAnyNewDeployhere — the latter verified RED against the pinned 0.1.1 and green on 0.1.2. - Oplog growth on default-OFF nodes (check 8):
OnReadyregisters replication unconditionally, so a node with no peer accumulates unacked/unpruned oplog rows (~2/deploy). Slow, bounded by the 1M cap; a periodic pruner or a skip-if-unchanged cache write would close it. → FIXED at the source:LocalDbDeploymentArtifactCache.StoreAsyncnow skips entirely when the pointer already names this deployment/revision, the SHA matches the bytes, and the expected chunk count is present — so re-caching an artifact the node already holds (every restart's boot-from-cache, everyRestoreApplied) writes nothing and mints no oplog rows. Two guard tests pin what the skip must NOT do (stale bytes under an unchanged revision; a matching pointer over missing chunks), both verified RED against a naive pointer-only skip. Also correcting this row's bound: the growth was never headed for the 1M cap.AddZbLocalDbReplicationis registered unconditionally, soMaintenanceBackgroundServiceruns on default-OFF nodes too and the 7-dayMaxOplogAgecap prunes — the true bound was ~7 days of writes, not 1M rows.
Merged to master as c957db52 (2026-07-21) and pushed. Both follow-ups closed on
fix/localdb-phase1-followups (2026-07-21).