Phase 2's recon carries a STOP condition: a BLOB payload column in the legacy
alarm store-and-forward table could not be registered for replication and would
have to become base64 TEXT, sized against the chunk guidance. Resolved ahead of
execution so the answer survives into the Phase 2 session:
- No BLOB. All 8 columns are TEXT/INTEGER; the payload is PayloadJson TEXT NOT
NULL. No base64 conversion, no chunk sizing needed.
- The executed DDL (SqliteStoreAndForwardSink.cs:657-667) matches the class
doc-comment at :17-26 exactly — checked because a doc comment is not evidence.
- Payload is bounded by shape: a serialized AlarmHistorianEvent is 10 scalar
fields, no collections or nesting, so realistic worst case is low single-digit
KB against a 171 KB guidance.
- The PK IS autoincrement, confirming the plan's prediction that the migrator
must mint deterministic mig-{node}-{legacyId} ids and the new table needs a
TEXT GUID PK.
- Also captured for reuse: the drain's covering index (DeadLettered, RowId), and
the exact legacy column list the migrator's pragma_table_info intersection
check compares against.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
17 KiB
OtOpcUa LocalDb Adoption — Phase 2 Implementation Plan (alarm-historian store-and-forward)
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Execution model: Optimized for Claude Opus agents (
claude --model opus); dispatchhigh-risktasks on Opus. Branchfeat/localdb-phase2in~/Desktop/OtOpcUa(remotelmxopcua). Prerequisite: Phase 1 (docs/plans/2026-07-20-localdb-adoption-phase1.md) is merged (or this branch is stacked on it) and its live gate passed. Do not merge as part of this plan; stop at the DoD task.Design authority:
~/Desktop/scadaproj/docs/plans/2026-07-20-otopcua-localdb-design.md§3.4, D8, D9. Reference implementation for "replace a bespoke store" phasing: ScadaBridge Phase 2 (~/Desktop/ScadaBridge/docs/plans/2026-07-19-localdb-adoption-phase2.md+ its live gate doc).
Goal: Move the alarm-historian store-and-forward buffer (today the standalone
alarm-historian.db owned by SqliteStoreAndForwardSink) into the consolidated LocalDb file as a
replicated table with a primary-gated drain, so a redundant pair no longer loses buffered alarm
history when a node dies — and delete the sink's bespoke file/connection management outright.
Architecture: alarm_sf_events (TEXT GUID PK) registered in LocalDbSetup.OnReady; the sink
rewired onto ILocalDb behind its unchanged public seam; drain gated on the delivered-snapshot
Primary role via PrimaryGatePolicy (at-least-once across failover, accepted and documented); a
one-time idempotent migrator from the legacy file, running after registration.
Risk framing (from ScadaBridge): Phase 2 replaces a working mechanism — a harder risk class than Phase 1's "add where none existed." Cutover (delete + rewire) lands in one commit; there is no dual-mechanism period, and the cutover is the test.
Hard rules: identical to Phase 1's list (OnReady ordering, no autoincrement/BLOB, fail-closed auth already in place, no in-memory SQLite in tests, cp-triplet-only rig inspection), plus:
- Legacy-copy column lists must INTERSECT with what the legacy file actually has
(
pragma_table_infoprobe) — a missing column throws and readers silently discard every row. - Absence assertions need a positive control.
- DoD greps phrased as "no references from code" (explanatory comments may survive).
Task 0: Recon (produces docs/plans/2026-07-20-localdb-phase2-recon.md)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
docs/plans/2026-07-20-localdb-phase2-recon.md - Read-only:
src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs(and the wholeCore.AlarmHistorianproject),src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs, the drain worker (whatever forwards batches toGatewayHistorian/SendEvent),src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs,src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(how_localRole+ driver-member-count reach the gate today)
Record, with file:line citations:
-
The sink's exact table schema: name, columns, PK type (autoincrement? — decides whether the migrator needs deterministic
mig-{node}-{legacyId}ids), indices, and any BLOB columns (STOP condition: a BLOB payload column cannot be registered — it must become base64 TEXT in the new schema, and the recon must size the largest realistic payload against the 171 KB-ish chunk guidance; alarm events are small JSON, so expect this to be fine, but verify).PRE-ANSWERED 2026-07-21 (verified against
masterd218282c) — the STOP condition does NOT fire. Re-verify cheaply during recon, but do not expect a surprise:- Executed DDL:
SqliteStoreAndForwardSink.cs:657-667. It matches the class doc-comment at:17-26exactly — no drift between the documented and executed schema. - No BLOB. All 8 columns are TEXT/INTEGER; the payload column is
PayloadJson TEXT NOT NULL. So no base64 conversion is needed and the chunk-size sizing is moot. - Payload is small and bounded by shape.
PayloadJsonis a serializedAlarmHistorianEvent— 10 scalar fields (AlarmId,EquipmentPath,AlarmName,AlarmTypeName,Severity,EventKind,Message,User,Comment?,TimestampUtc). No collections, no nesting. Realistic worst case is low single-digit KB (operatorCommentis the only unbounded-ish field), far under the 171 KB chunk guidance. - PK IS autoincrement —
RowId INTEGER PRIMARY KEY AUTOINCREMENT. This confirms the plan's own prediction: LocalDb cannot replicate an autoincrement key, so the migrator must mint deterministicmig-{node}-{legacyId}ids, and the new table needs a TEXT GUID PK as already specified in the Architecture note. - One index to carry across:
IX_Queue_Drain ON Queue (DeadLettered, RowId)(:667) — the drain's covering index. Thealarm_sf_eventsequivalent wants the same shape over (dead-lettered flag, insertion order) so the drain query stays index-covered. - Legacy column list for the migrator's
pragma_table_infointersection check:RowId, AlarmId, EnqueuedUtc, PayloadJson, AttemptCount, LastAttemptUtc, LastError, DeadLettered.
- Executed DDL:
-
The public seam: the interface the drain worker and producers use (e.g.
IAlarmHistorianSink/ enqueue+dequeue+markDelivered+deadLetter methods), so the rewire can keep it byte-compatible. -
Semantics to preserve:
Capacity(1,000,000) enforcement,MaxAttempts(10), dead-letter retention (30 d),BatchSize(100),DrainIntervalSeconds(5) — where each lives. -
The drain worker's lifecycle: hosted service or actor? Where a Primary-role check can be injected, and how the delivered-snapshot role (
RedundancyStateChangedcache) is accessible from it (viaDriverHostActor, a shared status service, or a message). If the role is only available insideDriverHostActor, note the cleanest bridge (e.g. anIRedundancyRoleViewsingleton the actor updates) — that becomes Task 4's shape. -
Whether the sink is constructed per-node config path (
AlarmHistorian:DatabasePath) anywhere else (tests, tooling). -
How
AlarmHistorian:Enabled=falseshort-circuits (NullAlarmHistorianSink) — the rewire must keep the disabled path allocating no LocalDb tables? No: tables are created unconditionally inOnReady(cheap, empty); only the sink/drain stay Null. Note this in the doc.
Commit: docs(localdb): phase-2 recon findings.
Task 1: alarm_sf_events schema + registration (+ tests)
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (Task 2 depends on it)
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs(depends only onMicrosoft.Data.Sqlite) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs - Test: extend
LocalDbSetupTests
Schema shape (adjust column names to the recon's findings — preserve today's semantics):
CREATE TABLE IF NOT EXISTS alarm_sf_events (
id TEXT NOT NULL PRIMARY KEY, -- app-minted GUID (never autoincrement)
payload_json TEXT NOT NULL,
enqueued_at_utc TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | dead
last_attempt_utc TEXT NULL,
dead_at_utc TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_alarm_sf_events_status ON alarm_sf_events(status, enqueued_at_utc);
OnReady order becomes: Phase-1 DDL → AlarmSfSchema.Apply → the two Phase-1
RegisterReplicated calls → RegisterReplicated("alarm_sf_events") → migrator (Task 5) last.
(All DDL may run before all registrations; the invariant is registration-before-writes.)
TDD: failing test first — exact replicated set becomes
["alarm_sf_events", "deployment_artifacts", "deployment_pointer"] (ordinal-sorted; update the
Phase-1 exact-set pins in the same commit — they are supposed to go red here, that's them
working). Oplog-capture test for an alarm_sf_events insert. Commit
feat(localdb): alarm_sf_events replicated table.
Task 2: Rewire the sink onto ILocalDb + delete bespoke file management (the cutover commit, part 1 of 2 — see Task 3)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs(or replace withLocalDbStoreAndForwardSink.cs— keep the public seam identical either way) - Modify: its registration (
AddAlarmHistorian) to injectILocalDb - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs— removeDatabasePath(a breaking config key removal: note it in the runbook/CHANGELOG task) - Test: the sink's existing unit tests, rewired to a temp-file
ILocalDbviaTestLocalDb-style helper (createtests/.../TestSupporthelper if none exists — real DB, never a stub: a stubbed bareSqliteConnectionlackszb_hlc_next()and fails closed on registered tables)
Steps:
- Write/port failing tests for the seam's semantics: enqueue, drain batch of
BatchSize,MaxAttempts→ dead-letter, capacity enforcement, dead-letter retention purge. - Implement over
ILocalDb.ExecuteAsync/QueryAsync(anonymous-object params). Delete the private connection/pragma/file-open code and anyPRAGMA journal_modecalls (LocalDb owns pragmas). GUIDs minted at enqueue (Guid.NewGuid().ToString("N")). Capacityenforcement: count-based insert guard (preserve today's overflow behavior per recon).- Core.AlarmHistorian gains a package ref on core
ZB.MOM.WW.LocalDb(interface only). - Build + project tests green. Commit together with Task 3 if the drain gate can't compile separately (the ScadaBridge tasks-14/15/16 circular-dependency landmine — check before assuming they're independent commits).
Task 3: Primary-gated drain
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files: (exact shape from recon item 4)
- Modify: the drain worker
- Create (if recon says so):
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs— a singleton snapshot (RedundancyRole? LocalRole,int DriverMemberCount) updated byDriverHostActorwhere it already caches_localRole - Test: drain-worker tests + an actor test pinning that
DriverHostActorpublishes role changes to the view
Semantics:
- Drain runs only when
PrimaryGatePolicy.ShouldServiceAsPrimary(localRole, driverMemberCount)is true — same policy, same boot-window posture (unknown role drains only when the node is alone). - Delivered/dead-letter marks are row UPDATEs → they replicate, so the standby's copy tracks drain progress and does not re-deliver already-marked rows after failover.
- At-least-once across failover is accepted: rows delivered on the old primary whose
deliveredmark hadn't replicated yet will be re-sent by the new primary. Document in the runbook (Task 7); do NOT build dedup. - When replication is OFF (default), the gate still applies but
driverMemberCountfor a solo node keeps today's behavior — verify with a test: single-node, role unknown → drains (no regression for unpaired deployments).
TDD: failing tests — secondary role does not drain; primary drains; unknown+alone drains;
unknown+paired does not. Commit (with Task 2 if coupled):
feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover).
Task 4: One-time legacy migrator (alarm-historian.db → consolidated)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 6
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs - Modify:
LocalDbSetup.OnReady— call it last, after allRegisterReplicatedcalls - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/AlarmSfLegacyMigratorTests.cs
Mirror ~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs:
- Source path from the pre-removal
AlarmHistorian:DatabasePathdefault (alarm-historian.db) — read the raw config key even though the option property is gone. ShouldMigrate: skip if file missing or<file>.migratedexists.:memory:/file:sources → no-op.- If the legacy PK is autoincrement (recon): deterministic ids
mig-{NodeName}-{legacyId}(node name from the recon-identified config key) — rerunnable without duplicates underINSERT OR IGNORE, and no cross-node collision. If already GUIDs, copy as-is. - One transaction for the whole copy;
File.Move(path, path + ".migrated")only after commit; failure throws out ofOnReady→ boot fails, legacy untouched. - Column intersection via
pragma_table_infoon the legacy table; required-PK guard. - Both pair nodes migrate independently; their rows have distinct ids (node-prefixed), so the merged buffer is the union — expected; note that the new primary will drain the standby's migrated rows too.
TDD: failing tests — happy path row counts; idempotent re-run; failure leaves legacy file
untouched; migrated rows enter the oplog (assert __localdb_oplog — the registration-order
pin); older legacy file missing a column still migrates (intersection). Commit
feat(localdb): one-time alarm-historian.db migrator.
Task 5: Convergence + failover scenarios in the pair harness
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 4
Files:
- Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfConvergenceTests.cs(reuses the Phase-1LocalDbPairHarness)
Scenarios:
AlarmBurstOnA_ConvergesToB_AndOplogDrains— enqueue N events on A; identical rowset on B; oplog → 0.DeliveredMarksReplicate— mark rows delivered on A; B's copies show delivered (the no-redeliver-after-failover property, asserted at the data layer).WritesWhileTransportDown_SurviveRejoin.- Positive control: with
RegisterReplicated("alarm_sf_events")commented out, scenarios 1–3 go red (run locally, record, restore — do not commit red).
Commit test(localdb): alarm S&F convergence scenarios.
Task 6: Rig config + docs
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 4
Files:
- Modify:
docker-dev/docker-compose.yml— enableAlarmHistorian__Enabled=trueon the site-a pair if not already (the gate needs a live buffer); remove anyAlarmHistorian__DatabasePathenv vars (key deleted). - Modify:
docs/operations/2026-07-20-localdb-pair-replication.md— add: alarm S&F replication semantics, at-least-once-across-failover statement, migrator behavior (.migratedsidecar),DatabasePathkey removal. - Modify:
docs/AlarmHistorian.md+CLAUDE.md— sink now lives in the consolidated LocalDb; drain is primary-gated.
Commit docs+chore(localdb): phase-2 rig config + docs.
Task 7: DoD sweep (offline)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
- Full solution build → 0 warnings; full test suite green (deltas vs pre-branch baseline only).
- Greps, phrased as "no references from code": the old bespoke connection management
(
AlarmHistorian:DatabasePath, directnew SqliteConnectioninside Core.AlarmHistorian except via schema helpers/tests) — explanatory comments may remain. - Positive-control evidence from Task 5 recorded.
- Exact-set replicated-tables pin = 3 tables, both directions.
- Update
…phase2.md.tasks.json; commitchore(localdb): phase-2 DoD sweep; STOP and report.
Task 8: Live gate on the docker-dev rig (run only with explicit user go-ahead)
Classification: high-risk (rig) Estimated implement time: ~30 min wall-clock Parallelizable with: none
Same inspection rules as Phase 1's gate (cp-triplet, curl sidecar, observer-suspicion rule).
Record in docs/plans/2026-07-20-localdb-phase2-live-gate.md:
- Migration ran:
.migratedsidecar present on both site-a nodes; row counts match legacy. - Alarm burst (drive a real driver alarm or the historian-gateway-unreachable path) converges: identical rowsets, oplog drains to 0, dead letters 0.
- Only the primary drains: stop the historian gateway egress, buffer builds on BOTH nodes' tables (replicated), but only the primary's drain worker logs attempts.
- Failover: stop the primary; the standby (new primary) resumes draining the shared buffer; count of double-delivered events observed and recorded (at-least-once evidence, not a failure).
- Both-nodes-together restart clean; zero
disk I/O error/SQLITE_IOERRin logs. - site-b (default-OFF pin): sink works locally, no sync traffic.
Commit the gate doc; report; do not merge.
Task persistence
Tasks file: docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json. Record deviations per
task — especially if Tasks 2/3 had to land as one commit (the expected outcome).