The #485/#486 fixes changed nothing structural about OtOpcUa — same drivers, same dual-namespace scheme, same Galaxy flow — so the row is mostly untouched. What DID change is a fact any OPC UA consumer depends on, and ScadaBridge is mid-cutover onto exactly this address space: a transient ConfigDb error used to empty the served address space (a PureRemove of every node, observed live and it stayed empty), stop every driver and clear every subscription, while still reporting the deploy Applied. A consumer would have seen all bindings go bad with nothing upstream admitting a problem. Recorded on the OtOpcUa row. Also fixes real drift found while checking: the OtOpcUa LocalDb adoption design was sitting UNTRACKED here and unreferenced by the index, even though the LocalDb row claims the adoption designs live in this repo. Now committed, referenced alongside the ScadaBridge one, and its stale header corrected — it still said Phase 1 was "not yet executed" against lib 0.1.1, when Phase 1 shipped and merged against 0.1.3. Phase 2 designed but not executed, on both the doc and the row. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
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 is designed but NOT executed —
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 |