Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
18 KiB
OtOpcUa LocalDb Adoption — Design
Date: 2026-07-20
Status: Phase 1 EXECUTED, merged + pushed to OtOpcUa master 2026-07-21 (merge c957db52;
live gate 8/8, OtOpcUa/docs/plans/2026-07-20-localdb-phase1-live-gate.md). Both limitations that gate
left open were closed the same day (merge 5fdb5a5a, …-followups-live-gate.md), which turned up a
third library defect reachable only once the first was fixed. Phase 2 EXECUTED — correction 2026-08-07:
Phase 2 (alarm store-and-forward on LocalDb) was executed, live-gated and merged to OtOpcUa master
(merge 06b5d81b, "Merge feat/localdb-phase2: alarm store-and-forward on LocalDb + live-gate fixes";
source: src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs + LocalDbStoreAndForwardSink.cs).
The "designed but NOT executed" claim that previously stood here was stale. Residual: live-gate checks 3+4
were never re-run on the post-mesh-Phase-6 topology.
Plan at OtOpcUa/docs/plans/2026-07-20-localdb-adoption-phase2.md (9 tasks).
Implementation plans live in the OtOpcUa repo at
docs/plans/2026-07-20-localdb-adoption-phase1.md and …-phase2.md.
Library: ZB.MOM.WW.LocalDb / .Replication / .Contracts — designed against 0.1.1; Phase 1
shipped against 0.1.3, which carries the two rebuilt-peer replication fixes the live gates forced.
Predecessor: ScadaBridge adoption (merged 2026-07-20, PR #23) — this design deliberately reuses its
wiring pattern and every lesson from its two live gates. See §Lessons appendix.
Executor: plans are written to be executed by Claude Opus agents
(claude --model opus + superpowers-extended-cc:executing-plans).
1. Decisions (with alternatives considered)
Per-decision best-practice fit noted; the recommended option is what this design adopts.
| # | Decision | Chosen | Alternatives considered |
|---|---|---|---|
| D1 | What OtOpcUa caches locally | Phase 1: the deployed-configuration artifact (Deployment.ArtifactBlob + current-pointer), giving boot-from-cache when central SQL is unreachable. Phase 2: the alarm-historian store-and-forward buffer (today's standalone alarm-historian.db). |
Secrets store (already has its own Akka replication — leave alone); FasterLog historization outbox (not SQLite, already crash-safe — leave alone); alarm condition state (central SQL by design, survives failover via central — defer). |
| D2 | Fate of the dormant LiteDB LocalCache (ILocalConfigCache, GenerationSealedCache, ResilientConfigReader — built, unit-tested, never registered) |
Delete in Phase 1. LocalDb supersedes it; keeping two unwired cache designs invites the next reader to wire the wrong one. The useful ideas (fail-closed cache miss, stale-config flag) are re-expressed on LocalDb. | Wire LiteDB as-is (rejected: second embedded engine, no replication, dead since Phase 6.1); back ILocalConfigCache with LocalDb behind the existing interface (rejected: the interface models LiteDB generations, not the artifact/pointer shape v3 actually ships). |
| D3 | Sync transport | Dedicated Kestrel h2c (HTTP/2-only) listener + the library's gRPC stream, exactly like ScadaBridge's port 8083. New config key LocalDb:SyncListenPort (default 0 = no listener). |
Ride the Akka mesh like Secrets replication (rejected: the LocalDb sync engine is gRPC-stream-only; an Akka transport is a library fork, out of scope); multiplex on the existing :9000 HTTP/1.1 listener (rejected: cleartext Http1AndHttp2 cannot negotiate h2c without ALPN — prior-knowledge h2c needs an Http2-only listener). |
| D4 | Rollout gating | Storage move ships unconditionally (LocalDb with replication off is just a local SQLite file); replication is config-gated default-OFF (PeerAddress unset, SyncListenPort 0) and proven on the docker-dev rig's site-a pair only, with site-b left unreplicated as the default-OFF pin — the ScadaBridge posture, and consistent with OtOpcUa's existing Secrets:Replication:Enabled default-false gate. |
Big-bang enable everywhere (rejected: no production KEK-style story yet for sync ApiKey distribution; default-OFF has served the family well). |
| D5 | DB layout | One consolidated per-node file, LocalDb:Path default ./data/otopcua-localdb.db. The library is one-DB-per-process (AddZbLocalDb first-registration-wins). |
Per-concern files (impossible without lib changes). |
| D6 | Artifact storage shape | Chunked base64 TEXT rows (deployment_artifacts, PK (deployment_id, chunk_index), raw chunk 128 KiB) + a deployment_pointer row per cluster with a whole-artifact SHA-256. Reason: RegisterReplicated rejects BLOB columns, and MaxBatchSize batches by row count against gRPC's 4 MB default cap — a single unchunked multi-MB artifact row would be undeliverable, full stop (the ScadaBridge "if any single row approaches 4 MB → STOP" rule, designed out from day one). |
Single base64 row + low MaxBatchSize (rejected: caps artifact size at ~3 MB forever, silently); store artifact on disk beside the DB and replicate only metadata (rejected: reintroduces the file-vs-row consistency problem replication exists to solve). |
| D7 | Which nodes run LocalDb | Driver-role nodes only (hasDriver), like the alarm historian. Admin-only nodes have nothing to cache; skipping them avoids the LocalDb:Path-required-or-no-boot constraint there. The rig's central pair is admin,driver, so it still gets LocalDb. |
All nodes (rejected: pointless required-config on admin-only nodes). |
| D8 | Phase 2 drain ownership | Primary-gated drain: only the node whose delivered-snapshot role is Primary (via PrimaryGatePolicy) drains the replicated alarm S&F buffer to HistorianGateway. Failover semantics: at-least-once (a failover window may re-send events whose delivered-mark hadn't replicated); accepted and documented — same class as ScadaBridge's convergent-not-identical decision. |
Both nodes drain with distributed dedup (rejected: complexity; the historian has no idempotence key for SendEvent). |
| D9 | Bespoke-mechanism deletion | Phase 2 rewires SqliteStoreAndForwardSink onto ILocalDb and deletes its private file/pragma management outright — cutover in one commit, no dual-mechanism period (the ScadaBridge Phase-2 rule: cutover is the test). Public seam (IAlarmHistorianSink or equivalent) unchanged. |
Parallel-run both sinks (rejected: double-delivery to the historian, and the family precedent is clean cutover). |
2. Current state (inventoried 2026-07-20)
| Store | File / location | Mechanism today | Replicated? | Fate |
|---|---|---|---|---|
| Deployed config | central SQL Deployment.ArtifactBlob; fetched by DriverHostActor on DispatchDeployment (DPS topic deployments); no local fallback — node goes Stale and ignores deploys when SQL is down |
EF Core, push-then-pull | n/a (central) | Phase 1: cached per-pair in LocalDb; boot-from-cache when central unreachable |
| LiteDB config cache | config_cache.db (default) — never created in practice; code never registered |
LiteDB 5, dormant | no | Phase 1: deleted |
| Alarm-historian S&F | alarm-historian.db (AlarmHistorian:DatabasePath), per-node |
bespoke SqliteStoreAndForwardSink, own file/pragmas |
no | Phase 2: table in the consolidated LocalDb, replicated, primary-gated drain |
| Secrets | otopcua-secrets.db per node |
ZB.MOM.WW.Secrets + optional Akka replication |
when enabled | untouched |
| Continuous-historization outbox | FasterLog dir (ContinuousHistorization:OutboxPath) |
FasterLog | no | untouched |
3. Design
3.1 Shape
- Host gains
AddOtOpcUaLocalDb(services, config)(newLocalDbRegistrationinsrc/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/, mirroringSecretsRegistration), called fromProgram.csinside thehasDriverbranch. It runsAddZbLocalDb(config, LocalDbSetup.OnReady)+AddZbLocalDbReplication(config)unconditionally (inert withoutPeerAddress). LocalDbSetup.OnReadyenforces the load-bearing order: DDL →RegisterReplicated→ (no migrator in Phase 1; Phase 2 adds one) → first writes.- Sync endpoint:
AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>())+app.MapZbLocalDbSync(), served on a dedicatedHttp2-only cleartext listener bound iffLocalDb:SyncListenPort > 0. Because explicitListen*calls make Kestrel ignoreASPNETCORE_URLS, the existing:9000HTTP/1.1 binding must be re-added explicitly in the sameConfigureKestrelblock — this is called out as a high-risk task with its own rig verification. - Auth: consumer-supplied fail-closed bearer interceptor (fixed-time compare, scoped to
/localdb_sync.v1.LocalDbSync/), a line-for-line port of ScadaBridge's — the library's passive side verifies nothing.ApiKeyproduction value is a${secret:}reference (OtOpcUa's pre-hostSecretReferenceExpanderalready expands these).
3.2 Schema (Phase 1)
CREATE TABLE IF NOT EXISTS deployment_artifacts (
deployment_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
cluster_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
chunk_count INTEGER NOT NULL,
chunk_base64 TEXT NOT NULL,
cached_at_utc TEXT NOT NULL,
PRIMARY KEY (deployment_id, chunk_index)
);
CREATE TABLE IF NOT EXISTS deployment_pointer (
cluster_id TEXT NOT NULL PRIMARY KEY,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
artifact_sha256 TEXT NOT NULL,
applied_at_utc TEXT NOT NULL
);
Both registered. All PKs are TEXT/composite (no autoincrement — the LWW-overwrite trap). Raw chunk size
128 KiB (≈ 171 KB base64); with the rig MaxBatchSize pin of 16, worst-case batch ≈ 2.7 MB, under
the 4 MB gRPC cap. Retention: keep the newest 2 deployments per cluster_id; older artifact rows are
deleted on store (deletes replicate as tombstones — fine). Reassembly verifies chunk_count and the
pointer's artifact_sha256 before deserializing; any mismatch = cache miss, never a partial apply.
Both pair nodes write the same rows after applying the same deployment; identical-content rows LWW-converge (the ScadaBridge dual-migrate precedent — expected, not an anomaly).
3.3 Boot-from-cache behavior (Phase 1)
New seam IDeploymentArtifactCache (Runtime project; Runtime takes a package ref on the core
ZB.MOM.WW.LocalDb only — the ScadaBridge SiteRuntime pattern):
- Write path:
DriverHostActor, immediately after a successful artifact apply, stores the artifact + advances the pointer (fire-and-forget with logging; a cache-write failure never fails the apply). - Read path: at actor start, when the central fetch of the current deployment fails (SQL unreachable /
Stale), fall back toGetCurrentAsync(clusterId); on hit, apply the cached artifact and surface a running-from-cache signal (log + health degradation), mirroring the deadStaleConfigFlagintent.DispatchDeploymenthandling is unchanged — a new deployment still requires central (the cache holds the past, not the future). Cache miss keeps today's behavior exactly (Stale, no config). - The replication payoff: a standby that never applied deployment N still holds its artifact (replicated from its pair peer) and can boot-from-cache with SQL down.
The exact integration points inside DriverHostActor are pinned by a mandatory recon task at the start of
the Phase 1 plan (with STOP conditions), because that actor's fetch/boot lifecycle was mapped from docs,
not stepped through.
3.4 Phase 2 — alarm-historian S&F onto LocalDb
alarm_sf_eventstable (TEXT GUID PK; exact columns derived from today'sSqliteStoreAndForwardSinkschema by the Phase 2 recon task; status/attempts/dead-letter semantics preserved as columns).- Sink rewired onto
ILocalDb(public seam unchanged); its private connection/pragma/file management andAlarmHistorian:DatabasePathare deleted.AlarmHistorian:Enabledstill gates the whole feature. - One-time migrator: legacy
alarm-historian.db→ consolidated file, run afterRegisterReplicatedinsideOnReady,INSERT OR IGNORE, column-intersection copy, deterministic ids for any autoincrement legacy PK (mig-{node}-{id}), single transaction,File.Move(path, path + ".migrated")only after commit. - Drain: primary-gated via the delivered-snapshot role (
PrimaryGatePolicy.ShouldServiceAsPrimary); delivered/dead-letter marks are row updates that replicate, so the standby's copy tracks drain progress. At-least-once across failover, documented.
3.5 Error handling
Library defaults stand: fail-closed schema-digest handshake, per-entry dead-letter for poison rows,
oplog caps degrade to snapshot resync (never data loss). App-side: cache-write failures are logged and
dropped (apply already succeeded); cache-read integrity failures are a miss; the interceptor is fail-closed
(no/empty ApiKey ⇒ PermissionDenied for every sync call — a key typo stops convergence, it does not
degrade to unauthenticated).
3.6 Observability & health
ISyncStatus→ aZB.MOM.WW.Healthcheck (Degraded when a peer is configured butConnectedis false, orOplogBacklogis null/climbing), registered in the existingAddOtOpcUaHealthpath.- The meter
ZB.MOM.WW.LocalDb.Replicationmust be added to OtOpcUa's telemetry meter list ifAddOtOpcUaObservabilityuses an allowlist —ZbTelemetryOptions.Metersis a silent allowlist and this exact omission is what the ScadaBridge live gate caught. The plan has an explicit task for it.
3.7 Testing & definition of done
- DI-pin tests over the real
Program.csviaWebApplicationFactory<Program>(Host.IntegrationTests, xunit.v3): exact replicated-table set (both directions load-bearing), PK pins,ILocalDbsingleton, default-OFF pin (ISyncStatus.Connected == false,PeerNodeIdnull), driver-role gating (admin-only graph has noILocalDb). - Interceptor unit tests with a hand-rolled fake
ServerCallContext(Grpc.Core.Testingdoes not exist on grpc-dotnet). - 2-node convergence harness over a real loopback Kestrel h2c transport through the real interceptor,
initialized via the production
LocalDbSetup.OnReady(a hand-written schema would only prove the test agrees with itself) — copied from the library'sConvergenceFixture/ ScadaBridge'sLocalDbSitePairHarness. DBs registered as pre-constructed singletons so transport teardown doesn't dispose them. Real temp files, never:memory:;SqliteConnection.ClearAllPools()on cleanup. - Positive control (DoD): with the
RegisterReplicatedcalls commented out, the convergence scenarios must go red. Absence assertions get positive controls. - Actor tests (cache write/read fallback) live in an xunit2 TestKit project —
Akka.TestKit.Xunit2cannot be used from the xunit.v3 Host.IntegrationTests project. - Live gate on the docker-dev rig (site-a pair; adapted from ScadaBridge's 10 checks): artifact
chunks + pointer converge byte-identical with identical HLC + origin node id; standby-that-never-fetched
boots from cache with SQL stopped; retention prune tombstones on both nodes; oplog rise-and-drain across
a transport kill; both-nodes-together restart clean; metrics present on
/metrics; zerodisk I/O error/SQLITE_IOERRin logs. All DB sampling viacpof thedb/-wal/-shmtriplet — never hostsqlite3against a live bind-mounted WAL DB (the 2026-07-20 incident).
4. Operational constraints (inherited, to be documented in OtOpcUa's runbooks)
- A replicated pair must be stopped/started together across upgrades (no rolling-upgrade support; the schema-digest handshake fails closed on any drift).
- A node offline beyond
TombstoneRetention(default 7 d) can resurrect deleted rows. MaxBatchSizeis a row count against the 4 MB gRPC default cap — the rig pins 16; chunking keeps the worst-case row ≈ 171 KB.- The sync
ApiKeymust be byte-identical on both nodes (fail-closed interceptor). - Never inspect a live bind-mounted WAL DB with host
sqlite3; copy the triplet.
5. Out of scope
- Replicating secrets via LocalDb (Secrets keeps its own Akka replicator).
- The FasterLog continuous-historization outbox.
- Alarm condition / scripted-alarm state (stays central SQL).
- N>2 topologies, an Akka transport for LocalDb sync, byte-aware batching (library-side futures).
- Production enablement (needs a sync-ApiKey distribution decision, like the Secrets KEK one).
Appendix — ScadaBridge lessons applied
| Lesson (source: ScadaBridge Phases 1–2 + live gates) | Where this design applies it |
|---|---|
| DDL → RegisterReplicated → migrate → writes; pre-registration rows are invisible forever, silently | §3.1 OnReady ordering; Phase 2 migrator runs last, pinned by an oplog assertion |
| Autoincrement PKs are an LWW overwrite trap | All new tables TEXT/composite PKs; Phase 2 recon checks the legacy alarm PK, deterministic mig- ids if needed |
Library passive side verifies no ApiKey; MapZbLocalDbSync() can't carry auth on its return value |
Consumer interceptor, fail-closed, ported line-for-line (§3.1) |
ZbTelemetryOptions.Meters is a silent allowlist |
Explicit meter-allowlist task (§3.6) |
| Register in the graph your tests actually build | OtOpcUa's composition root is Program.cs and WebApplicationFactory<Program> builds it — DI pins run against the real graph (§3.7) |
Row-count MaxBatchSize vs 4 MB gRPC cap; single row near 4 MB = STOP |
Chunked artifact storage designed in (D6), rig pin 16 |
| No rolling upgrades; stop/start pair together | §4, runbook task |
| Tombstone-retention resurrection | §4 |
CreateConnection() returns an already-open, UDF-registered connection; no in-memory mode |
Plan task notes; test harness uses temp files + ClearAllPools |
| Absence assertions need a positive control; DoD greps must say "no references from code" | §3.7 DoD; deletion tasks in both plans phrase greps that way |
Never host-sqlite3 a live WAL DB; docker exec curl doesn't exist in aspnet:10.0 |
Live-gate procedure (§3.7, §4); gate uses cp triplet + curlimages/curl sidecar |
Grpc.Core.Testing doesn't exist on grpc-dotnet |
Interceptor tests hand-roll ServerCallContext |
| Observer-induced failures: check the observer's causal path before blaming the component | Live-gate preamble in both plans |