1556ae04e4
Execution review against the code found the integer site_events.id is
load-bearing in three places the original Task 5 did not list:
- keyset cursor EventLogQueryService.cs:70,141,153 (id > $afterId)
- storage-cap purge EventLogPurgeService.cs:164 (ORDER BY id ASC)
- wire contracts EventLogEntry.Id, both ContinuationTokens (long)
cross the site<->central Akka boundary
A GUID PK against the existing cursor silently drops rows, and against the
purge deletes arbitrary rows instead of the oldest. Task 5 is therefore split
into 5a (writer, standard) and 5b (read path + DTOs, high-risk), with 5b's
full file set spelled out and Task 10 gated on it.
Also recorded: Task 4's real registration site, and that both legacy DB paths
are CWD-relative and unset in docker today (so they live outside the data
volume and are already lost on container recreate - Phase 1 fixes that).
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
440 lines
28 KiB
Markdown
440 lines
28 KiB
Markdown
# ScadaBridge LocalDb Adoption — Phase 1 Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Site node pairs stop losing operation-tracking and site-event state on failover: both stores move into one `ZB.MOM.WW.LocalDb`-managed database with optional (default-OFF) 2-node replication, live-proven on the docker rig.
|
|
|
|
**Architecture:** One consolidated LocalDb file per site node (`LocalDb:Path` → `/app/data/site-localdb.db`). `OperationTracking` + `site_events` become `RegisterReplicated` tables. Stores keep their public interfaces and their own connection-management style, but obtain connections from `ILocalDb.CreateConnection()` (the lib registers the `zb_hlc_next()` UDF per-connection — a foreign `SqliteConnection` writing to a registered table FAILS, so every write path must go through the lib). Replication (initiator = node-a dialing node-b:8083, bearer-key-gated passive endpoint) is config-gated and default OFF. Design doc: `~/Desktop/scadaproj/docs/plans/2026-07-19-scadabridge-localdb-design.md`.
|
|
|
|
**Tech Stack:** .NET 10, `ZB.MOM.WW.LocalDb` 0.1.0 + `.Replication` + `.Contracts` (Gitea feed), Microsoft.Data.Sqlite, gRPC on the existing site Kestrel (8083), xunit.
|
|
|
|
**Execution configuration (saved for later execution):**
|
|
- Resume with `/superpowers-extended-cc:executing-plans docs/plans/2026-07-19-localdb-adoption-phase1.md` — the co-located `.tasks.json` carries the dependency graph.
|
|
- **Model: Opus for every implement subagent** (`model: "opus"` on Agent dispatch). Review chain per task classification (high-risk tasks get serial spec + code review, small tasks a single Sonnet/Haiku code-reviewer pass).
|
|
- **Dispatch tasks in waves — every task in a wave runs concurrently** (file sets are disjoint; verified below). Parallel implementers editing one repo MUST use worktree isolation (`Agent isolation: "worktree"`) — concurrent git in a shared worktree races destructively.
|
|
|
|
| Wave | Tasks (concurrent) |
|
|
|---|---|
|
|
| 1 | Task 1 (packages) |
|
|
| 2 | Task 2 (schema helpers) |
|
|
| 3 | Task 3 (composition root) |
|
|
| 4 | Task 4 (tracking store) ∥ Task 5a (event writer) ∥ Task 7 (replication wiring) |
|
|
| 5 | Task 5b (event read path) ∥ Task 6 (migrator) ∥ Task 8 (auth interceptor) ∥ Task 9 (health signal) |
|
|
| 6 | Task 10 (convergence test) ∥ Task 11 (rig config) |
|
|
| 7 | Task 12 (live gate — serial, on the real rig) |
|
|
| 8 | Task 13 (docs) ∥ Task 14 (Phase 2 planning) |
|
|
|
|
**Hard constraints learned from the code (do not rediscover):**
|
|
- `AddZbLocalDb(config, onReady)` is one-DB-per-process, first-registration-wins. Table DDL must run inside `onReady` **before** `RegisterReplicated`; rows inserted before registration are never captured/snapshotted.
|
|
- `site_events.id` must change from `INTEGER AUTOINCREMENT` to TEXT (GUID) — two nodes minting the same autoincrement id silently overwrite each other under LWW.
|
|
- The lib's passive sync endpoint does **not** verify the `ApiKey`; ScadaBridge must gate `/localdb_sync.v1.LocalDbSync/` itself (server interceptor, fixed-time compare).
|
|
- `MapZbLocalDbSync()` returns `IEndpointRouteBuilder` (not the gRPC convention builder), so auth cannot be attached via the return value — hence the interceptor.
|
|
- SQLitePCLRaw must stay pinned at 2.1.12 (family-wide advisory; ScadaBridge already pins it — do not remove the pin).
|
|
|
|
---
|
|
|
|
### Task 1: Add LocalDb packages to the build
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** none (everything depends on it)
|
|
|
|
**Files:**
|
|
- Modify: `Directory.Packages.props` (package versions block, near the `Microsoft.Data.Sqlite` entry at line ~33)
|
|
- Modify: `NuGet.config` (packageSourceMapping, dohertj2-gitea block)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ZB.MOM.WW.ScadaBridge.SiteEventLogging.csproj`
|
|
|
|
**Step 1:** Add central package versions:
|
|
|
|
```xml
|
|
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.0" />
|
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.0" />
|
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.0" />
|
|
```
|
|
|
|
**Step 2:** Add feed mapping patterns to the `dohertj2-gitea` packageSource block in `NuGet.config`:
|
|
|
|
```xml
|
|
<package pattern="ZB.MOM.WW.LocalDb" />
|
|
<package pattern="ZB.MOM.WW.LocalDb.*" />
|
|
```
|
|
|
|
**Step 3:** Add `<PackageReference Include="ZB.MOM.WW.LocalDb" />` to SiteRuntime + SiteEventLogging csproj; add `ZB.MOM.WW.LocalDb` **and** `ZB.MOM.WW.LocalDb.Replication` to Host csproj.
|
|
|
|
**Step 4:** Run `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect success, 0 warnings (TreatWarningsAsErrors). Verify restore came from the Gitea feed, not a 404.
|
|
|
|
**Step 5:** Commit: `git commit -m "feat(localdb): reference ZB.MOM.WW.LocalDb 0.1.0 packages"`
|
|
|
|
---
|
|
|
|
### Task 2: Shared schema helpers (tracking + events, GUID PK)
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingSchema.cs`
|
|
- Create: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogSchema.cs`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs`
|
|
|
|
The DDL must be callable from the Host's `AddZbLocalDb` onReady (which owns table creation), from the stores' existing idempotent init, and from tests. Extract each table's DDL into a static `Apply(SqliteConnection)` helper in its owning project.
|
|
|
|
**Step 1: Write failing tests** — each test opens an in-memory `SqliteConnection`, calls `Apply` twice (idempotency), and asserts via `PRAGMA table_info` that (a) the table exists, (b) `site_events.id` is `TEXT` and `pk == 1` with **no** autoincrement, (c) `OperationTracking` columns match the current store schema including `SourceNode`.
|
|
|
|
**Step 2:** Run the two test files — expect FAIL (types don't exist).
|
|
|
|
**Step 3: Implement.** `OperationTrackingSchema.Apply` = verbatim move of the DDL + `AddColumnIfMissing("SourceNode", …)` logic from `OperationTrackingStore.InitializeSchema` (`OperationTrackingStore.cs:74-116`). `SiteEventLogSchema.Apply` = the DDL from `SiteEventLogger.cs:146-160` **changed to**:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS site_events (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
timestamp TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
severity TEXT NOT NULL,
|
|
instance_id TEXT,
|
|
source TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
details TEXT
|
|
);
|
|
-- same four indexes as today
|
|
```
|
|
|
|
Keep both helpers free of any LocalDb dependency (plain `Microsoft.Data.Sqlite`) so the owning projects don't need new references for this task alone. Rewire `OperationTrackingStore.InitializeSchema` and `SiteEventLogger.InitializeSchema` to delegate to the helpers (behavior-preserving for tracking; the events PK change lands here and is completed by Task 5's writer change).
|
|
|
|
**Step 4:** Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests` and `…SiteEventLogging.Tests` — new tests PASS. Pre-existing SiteEventLogging tests that insert rows will now fail on the missing id — fix them in this task by supplying GUID ids where the test writes rows directly (the production writer changes in Task 5; if a production-code insert breaks compilation of tests, coordinate: this task may leave SiteEventLogging.Tests red ONLY for writer-path tests fixed in Task 5 — prefer landing Tasks 2+5 in one PR-sized push).
|
|
|
|
**Step 5:** Commit.
|
|
|
|
---
|
|
|
|
### Task 3: Register LocalDb in the site composition root
|
|
|
|
**Classification:** high-risk (composition-root wiring — the family's known failure class)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (insert after `AddSiteRuntime`, ~line 51)
|
|
- Modify: `docker/site-a-node-a/appsettings.Site.json` (+ the other five site-node appsettings): add `"LocalDb": { "Path": "/app/data/site-localdb.db" }`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs`
|
|
|
|
**Step 1: Write the failing DI-graph test.** Follow the existing pattern from the ScadaBridge#22 fix (`Host.Tests` has WebApplicationFactory-style DI tests over the real registration): build a ServiceCollection via `SiteServiceRegistration.Configure` with an in-memory config supplying `LocalDb:Path` = temp file, resolve `ILocalDb`, and assert `ReplicatedTables` contains `OperationTracking` and `site_events`. This is the test that would have caught the Secrets inert-replicator bug — it builds the real graph.
|
|
|
|
**Step 2:** Run it — FAIL (no registration).
|
|
|
|
**Step 3: Implement `SiteLocalDbSetup`:**
|
|
|
|
```csharp
|
|
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
/// <summary>
|
|
/// onReady for the consolidated site LocalDb: creates the replicated tables and
|
|
/// opts them into capture. Runs before any store resolves ILocalDb; DDL must
|
|
/// precede RegisterReplicated because pre-registration rows are never captured.
|
|
/// </summary>
|
|
public static class SiteLocalDbSetup
|
|
{
|
|
public static void OnReady(ILocalDb db)
|
|
{
|
|
using (var conn = db.CreateConnection())
|
|
{
|
|
OperationTrackingSchema.Apply(conn);
|
|
SiteEventLogSchema.Apply(conn);
|
|
}
|
|
db.RegisterReplicated("OperationTracking");
|
|
db.RegisterReplicated("site_events");
|
|
}
|
|
}
|
|
```
|
|
|
|
In `SiteServiceRegistration.Configure`, after `AddSiteRuntime` (line ~51):
|
|
|
|
```csharp
|
|
// Consolidated site LocalDb — OperationTracking + site_events live here as
|
|
// replicated tables (design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md).
|
|
// Replication itself is registered in Program.cs (needs the WebApplication gRPC host);
|
|
// storage is unconditional — with no peer configured this is just a local SQLite DB.
|
|
services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady);
|
|
```
|
|
|
|
**Step 4:** Run the wiring test — PASS. Run `dotnet build` — 0 warnings.
|
|
|
|
**Step 5:** Commit.
|
|
|
|
---
|
|
|
|
### Task 4: Rewire OperationTrackingStore onto ILocalDb connections
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 5, Task 7
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs:74` (the `IOperationTrackingStore` registration; `AddSiteRuntime`'s connection-string parameter becomes vestigial for this store — leave the parameter alone, it still feeds the site config DB)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/` (existing store tests)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs:433` (`Site_Resolves_IOperationTrackingStore` — it overrides the connection string to a temp path because the store opens eagerly in its ctor; that override changes shape once the store takes `ILocalDb`)
|
|
|
|
**Step 1:** Constructor gains `ILocalDb localDb`; `_writeConnection = localDb.CreateConnection()` replaces `new SqliteConnection(_connectionString)` + `Open()` (verified in the lib, `SqliteLocalDb.cs:83-98`: CreateConnection returns an **open** connection with per-connection pragmas — `synchronous`, `busy_timeout`, `foreign_keys=ON` — and the `zb_hlc_next` UDF registered; do NOT call `Open()` on it again). Reader paths that open ad-hoc connections from `_connectionString` switch to `localDb.CreateConnection()` too (reads don't fire triggers, but one connection source is one less config knob). `OperationTrackingOptions.ConnectionString` becomes unused by the store — keep the options class but drop the store's dependency; leave a validator relaxation only if the DI-graph test demands it.
|
|
|
|
**Step 2:** Update existing store tests to construct via a real `ILocalDb` over a temp file (the lib package is test-referenceable) instead of a raw connection string. Schema now comes from `OperationTrackingSchema.Apply` in setup.
|
|
|
|
**Step 3:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests` — PASS.
|
|
|
|
**Step 4:** Commit.
|
|
|
|
---
|
|
|
|
### Task 5a: Rewire SiteEventLogger onto ILocalDb + mint GUID ids
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 4, Task 7
|
|
|
|
> **Amended 2026-07-19 (execution review).** The original Task 5 said "any `ORDER BY id`
|
|
> becomes `ORDER BY timestamp`" and listed 3 files. Code recon found the integer `id` is
|
|
> load-bearing in three separate places beyond the writer — a keyset cursor, the
|
|
> storage-cap purge, and `long`-typed DTOs that cross the site↔central Akka boundary.
|
|
> Task 5 is therefore split: **5a = the writer** (this task, self-contained), **5b = the
|
|
> read/purge/contract change** (high-risk, its own task). Do not fold 5b back into 5a.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs` (AddSiteEventLogging registration)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/` (writer-path tests only)
|
|
|
|
**Step 1:** `SiteEventLogger` constructor gains `ILocalDb localDb`; its single owned `_connection` (`SiteEventLogger.cs:34,62-65`) becomes `localDb.CreateConnection()` — already open, pragma-configured, HLC-UDF-registered; do **not** call `Open()` again. Delete the `connectionStringOverride` seam (`:50`) rather than preserving it: a raw connection lacks `zb_hlc_next()`, so any test writing through the override against a registered table now **fails closed**. Switch the test fixture to a real `ILocalDb` over a temp file.
|
|
|
|
**Step 2:** Preserve the `internal WithConnection` seam (`:104`, `:123`) — `EventLogQueryService` and `EventLogPurgeService` both depend on it deliberately (their ctors take the concrete `SiteEventLogger`, not the interface). Its body changes connection source only; the signature and locking semantics stay identical, so 5b's consumers keep compiling.
|
|
|
|
**Step 3:** The INSERT (`:237-240`) gains an `id` column bound to `Guid.NewGuid().ToString("N")`, minted per event in the writer loop. `ISiteEventLogger.LogEventAsync`'s signature is unchanged — all 18 fire-and-forget call sites are untouched.
|
|
|
|
**Step 4:** Fix writer-path tests from Task 2. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests` — writer tests PASS. Query/purge tests may be red here; 5b closes them. Note that in the commit message.
|
|
|
|
**Step 5:** Commit.
|
|
|
|
---
|
|
|
|
### Task 5b: Migrate the event-log read path off integer ids
|
|
|
|
**Classification:** high-risk (cross-cluster data contract + silent-data-loss paging bug)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 6, Task 8, Task 9
|
|
**Blocked by:** Task 5a
|
|
|
|
The GUID PK breaks three integer-id assumptions. All three must move together — shipping any one alone is a live bug.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs` (cursor `:68-71`, `ORDER BY id ASC` `:141`, `GetInt64(0)` `:153`, token mint `:181`)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs` (storage-cap delete `:162-166`)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryResponse.cs` (`EventLogEntry.Id` `long`→`string`; `ContinuationToken` `long?`→`string?`)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs` (`ContinuationToken` `long?`→`string?`)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:3127` (passes `null` today — verify it still compiles)
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor:247,268,271` (`_continuationToken` type)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/` (query + purge)
|
|
|
|
**Step 1: Write the failing tests first.**
|
|
- Paging: insert N events with known timestamps, page through with `PageSize` < N, assert **every** row is returned exactly once across pages and in chronological order. This test fails loudly against a naive `id > $afterId` GUID cursor — that is the point.
|
|
- Ordering: rows come back oldest-first regardless of GUID values.
|
|
- Purge: with the storage cap tripped, the **oldest** rows are deleted, not arbitrary ones.
|
|
|
|
**Step 2:** Run — FAIL.
|
|
|
|
**Step 3: Implement.**
|
|
- **Cursor** → composite keyset on `(timestamp, id)`, since `timestamp` alone is not unique:
|
|
`WHERE (timestamp > $afterTs) OR (timestamp = $afterTs AND id > $afterId)`, `ORDER BY timestamp ASC, id ASC`. The continuation token encodes both — use `"{timestamp}|{id}"` as the `string?` token; parse defensively and treat a malformed token as "start from the beginning" rather than throwing.
|
|
- **Purge** (`:162-166`) → `ORDER BY timestamp ASC` instead of `ORDER BY id ASC`. (The retention purge at `:128` already filters on `timestamp` and is unaffected.)
|
|
- **DTOs** → `Id` and both `ContinuationToken`s become `string`/`string?`; reader uses `GetString(0)`.
|
|
|
|
**Step 4: Wire-compat check.** These DTOs cross the site↔central Akka boundary (`SiteCommunicationActor.cs:193` → `CommunicationService.cs:304` → ManagementActor / CentralUI). Both sides ship in the same deployable and the rig redeploys as a unit, so no rolling-upgrade shim is needed — **but state that explicitly in the commit message**, and confirm no serializer manifest/binding pins these types by name+type (grep the Akka serialization setup). If a binding exists, stop and surface it.
|
|
|
|
**Step 5:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests` + full solution build (0 warnings) — PASS. Commit.
|
|
|
|
---
|
|
|
|
### Task 6: One-time legacy data migrator
|
|
|
|
**Classification:** high-risk (data migration)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 8, Task 9 (needs Tasks 4-5 done)
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs` (call migrator at the end of OnReady)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs`
|
|
|
|
Semantics (write the tests first, one per bullet):
|
|
- Legacy files located from the OLD config keys (`ScadaBridge:OperationTracking:ConnectionString`, `ScadaBridge:SiteEventLog:DatabasePath`); missing file ⇒ no-op.
|
|
- **Both keys are unset in every docker appsettings today**, so they fall back to the code defaults `"Data Source=site-tracking.db"` (`OperationTrackingOptions.cs:14`) and `"site_events.db"` (`SiteEventLogOptions.cs:10`) — **CWD-relative**, i.e. `/app/site-tracking.db` and `/app/site_events.db`, NOT `/app/data/`. Resolve relative paths against the process CWD; do not assume the data volume. (Consequence worth knowing: both DBs live outside the mounted volume today and are already lost on every container recreate — so on the rig the migrator will usually find nothing, and that is a legitimate no-op, not a failure. Phase 1 incidentally fixes that data-loss bug by consolidating into `/app/data/site-localdb.db`.)
|
|
- Runs **after** `RegisterReplicated` so migrated rows enter the oplog and replicate.
|
|
- Tracking rows copy with `INSERT OR IGNORE` (same TEXT PK ⇒ idempotent).
|
|
- Legacy `site_events` rows get **deterministic** ids — `"mig-{NodeName}-{legacyId}"` — NOT fresh GUIDs, so a crashed-then-rerun migration cannot duplicate events (`INSERT OR IGNORE` on the deterministic key). NodeName from `ScadaBridge:Node:NodeName`.
|
|
- The whole copy runs in one `ILocalDb.BeginTransactionAsync` transaction; the legacy file is renamed to `<name>.migrated` only after commit. Migration failure throws ⇒ host fails to boot (no half-state; legacy files untouched on rollback).
|
|
- Second boot (file already `.migrated`) ⇒ no-op.
|
|
|
|
Run the migrator tests + full Host.Tests; commit.
|
|
|
|
---
|
|
|
|
### Task 7: Replication registration + sync endpoint (default OFF)
|
|
|
|
**Classification:** high-risk (wiring + endpoint exposure)
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 4, Task 5 (needs Task 3 only; touches Program.cs, disjoint from the store files)
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` site block (`:515-536`)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs` (extend)
|
|
|
|
**Step 1:** In the site block: `builder.Services.AddZbLocalDbReplication(builder.Configuration);` next to `AddGrpc()` (line ~516), and `app.MapZbLocalDbSync();` next to the existing `MapGrpcService<SiteStreamGrpcServer>()` (line ~536). Kestrel already serves h2c HTTP/2 on 8083 — no listener changes.
|
|
|
|
**Step 2:** Extend the DI-graph test: with no `LocalDb:Replication:PeerAddress`, the host builds and the initiator idles (resolve `ISyncStatus`, assert not connected, no throw). This is the "replication is default-OFF and inert-but-not-broken" pin.
|
|
|
|
**Step 3:** Build + Host.Tests green; commit.
|
|
|
|
---
|
|
|
|
### Task 8: Bearer auth interceptor for the sync endpoint
|
|
|
|
**Classification:** high-risk (security)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 6, Task 9
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/LocalDbSyncAuthInterceptor.cs`
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (site `AddGrpc` gains `options.Interceptors.Add<LocalDbSyncAuthInterceptor>()`)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs`
|
|
|
|
The lib's passive service does not verify keys (documented). Fail-closed server interceptor, scoped by method path:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Gates the LocalDb passive sync endpoint. The library deliberately leaves
|
|
/// inbound auth to the host. Scoped to /localdb_sync.v1.LocalDbSync/ so the
|
|
/// existing SiteStream service is untouched. Fail-closed: no configured key
|
|
/// means NO sync stream is accepted, authenticated or not.
|
|
/// </summary>
|
|
public sealed class LocalDbSyncAuthInterceptor : Interceptor
|
|
{
|
|
private const string ServicePrefix = "/localdb_sync.v1.LocalDbSync/";
|
|
// read via IOptions<ReplicationOptions> — verified: AddZbLocalDbReplication binds
|
|
// section "LocalDb:Replication" with ValidateOnStart; ApiKey is string? (null =
|
|
// no key configured). Same key on both nodes; ${secret:} refs resolve through
|
|
// the existing pre-host expander.
|
|
// compare with CryptographicOperations.FixedTimeEquals over UTF8 bytes;
|
|
// non-sync methods pass through untouched.
|
|
}
|
|
```
|
|
|
|
Tests (TDD, write first): non-sync path passes with no key; sync path with no configured key → `PermissionDenied`; wrong bearer → `PermissionDenied`; correct bearer → passes through. Use the interceptor directly with a fake `ServerCallContext` (Grpc.Core.Testing) — no live channel needed. Commit.
|
|
|
|
---
|
|
|
|
### Task 9: Surface ISyncStatus as site health signal
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Task 6, Task 8
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (health wiring — follow the `AddSiteEventLogHealthMetricsBridge` precedent at :76)
|
|
- Test: extend `SiteLocalDbWiringTests`
|
|
|
|
Bridge `ISyncStatus` (`Connected`, `OplogBacklog` — **nullable**, null must surface as unknown, never as 0) into the site health report the same way `FailedWriteCount` is bridged. Metrics need no work — the `ZB.MOM.WW.LocalDb.Replication` meter flows through the existing `AddZbTelemetry` Prometheus export. Test: bridge registered + resolvable. Commit.
|
|
|
|
---
|
|
|
|
### Task 10: Two-node in-process convergence test
|
|
|
|
**Classification:** high-risk
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 11
|
|
**Blocked by:** Task 6, Task 8, **Task 5b** (scenario 3 reads events back through the query path)
|
|
|
|
**Files:**
|
|
- Create: `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs`
|
|
|
|
Model on the LocalDb lib's own integration suite (`~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/tests/` — two real SQLite DBs over a real gRPC pipe): two hosts running the ScadaBridge site schema (`SiteLocalDbSetup.OnReady`), replication enabled between them (loopback ports, shared ApiKey through the real interceptor). Scenarios:
|
|
1. Tracking row written on A → readable on B ≤ a few flush intervals.
|
|
2. Same op updated on both nodes → both converge to one winner (LWW).
|
|
3. Events logged on both nodes → union visible on both (GUID ids, no collisions).
|
|
4. B offline while A accumulates → B rejoins → convergence.
|
|
|
|
Offline, no docker. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter "FullyQualifiedName~LocalDbSitePairConvergence"` — PASS. Commit.
|
|
|
|
---
|
|
|
|
### Task 11: Docker rig enablement (site-a pair)
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 10
|
|
|
|
**Files:**
|
|
- Modify: `docker/site-a-node-a/appsettings.Site.json` — add `"Replication": { "PeerAddress": "http://<site-a-node-b container name>:8083", "ApiKey": "<dev key>" }` under `LocalDb` (take exact container hostname from `docker/docker-compose.yml`)
|
|
- Modify: `docker/site-a-node-b/appsettings.Site.json` — `"Replication": { "ApiKey": "<same dev key>" }` (passive; key for inbound verification)
|
|
- Site-b/site-c pairs stay unreplicated (flag-off posture proven side-by-side)
|
|
|
|
Plain dev key in rig config is acceptable (matches the rig's existing posture); note in the compose README that production uses a `${secret:}` ref. Commit.
|
|
|
|
---
|
|
|
|
### Task 12: Live gate on the docker rig
|
|
|
|
**Classification:** standard (verification, no code)
|
|
**Estimated implement time:** ~10 min wall (mostly waiting on the rig)
|
|
**Parallelizable with:** none
|
|
|
|
Per `docker/README.md` + memory gotchas (restart central-a+b together; Central needs ApiKeyPepper):
|
|
1. `bash docker/deploy.sh` — rebuild + redeploy the 8-node cluster.
|
|
2. Generate tracking + event activity (existing rig flows / CLI).
|
|
3. On both site-a nodes: `sqlite3 /app/data/site-localdb.db "SELECT count(*) FROM site_events"` etc. — counts converge; spot-check row equality.
|
|
4. `bash docker/failover-drill.sh` — after takeover, the new active node serves complete tracking/event history from before the flip.
|
|
5. Check `/metrics` on 8084 for `localdb_*` instruments; check the dead-letter table is empty.
|
|
6. Verify site-b (replication off) boots and behaves identically to before — the default-OFF pin, live.
|
|
|
|
Evidence (commands + output) goes in the PR/commit message. Any failure here loops back to the offending task — do not mark this plan done with a red gate.
|
|
|
|
---
|
|
|
|
### Task 13: Documentation truth pass
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 14
|
|
|
|
- ScadaBridge `CLAUDE.md`: consolidated-DB note, config keys, replication flag posture, deleted config keys (old tracking/eventlog paths) marked migration-only.
|
|
- scadaproj `CLAUDE.md` LocalDb component row: "no app adoption yet" → ScadaBridge Phase 1 adopted (state exactly what is and is not live, per the component-status-honesty convention).
|
|
- `SiteEventLogger` XML doc: remove the now-false "Not replicated to standby. On failover, the new active node starts a fresh log."
|
|
|
|
Commit; push per repo convention.
|
|
|
|
---
|
|
|
|
### Task 14: Phase 2 gate — plan the bespoke-replicator migration
|
|
|
|
**Classification:** standard (planning only)
|
|
**Estimated implement time:** planning session
|
|
|
|
**Blocked by:** Task 12 live gate PASS.
|
|
|
|
Phase 2 (config tables + `sf_messages` into the consolidated DB; **delete** `SiteReplicationActor` + StoreAndForward `ReplicationService`) gets its own implementation plan written against post-Phase-1 reality. Inputs: the design doc §1 Phase 2, the bespoke replicator's test intents (port before deletion), the N5 bounded-duplicate acceptance, and any oplog-cap/churn observations from the Phase-1 rig soak. Do not start Phase 2 work without that plan.
|
|
|
|
---
|
|
|
|
## Execution notes
|
|
|
|
- Family conventions bind: TreatWarningsAsErrors, red-first tests, container-built DI-graph tests for all wiring, `dotnet test` offline-green.
|
|
- Lib assumptions **verified against source 2026-07-19** (`~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/`): `CreateConnection()` returns an open, pragma-configured, UDF-registered connection (`SqliteLocalDb.cs:83-98`); `ReplicationOptions` binds from `LocalDb:Replication` with `ValidateOnStart`, `ApiKey` nullable, and a passive node (empty `PeerAddress`) starts its `SyncBackgroundService` and immediately idles (`ReplicationServiceCollectionExtensions.cs`).
|
|
- If any task needs files beyond its `Files:` block, that is a plan defect — surface it rather than silently expanding scope.
|