diff --git a/CLAUDE.md b/CLAUDE.md index 06c1f7e5..2e2db504 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,10 +219,13 @@ The server supports configurable OPC UA transport security via the `OpcUa:Enable The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and the same mxaccessgw (under distinct `MxAccess.ClientName` values) but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide. -## LocalDb pair-local config cache (Phase 1) +## LocalDb pair-local store (Phases 1 + 2) Every **driver-role** node keeps a consolidated `ZB.MOM.WW.LocalDb` SQLite database (the retired -LiteDB `LocalCache` subsystem was deleted as superseded). Phase-1 scope: it caches the +LiteDB `LocalCache` subsystem was deleted as superseded). It holds **three replicated tables**: +`deployment_artifacts` + `deployment_pointer` (Phase 1) and `alarm_sf_events` (Phase 2). + +**Phase 1** caches the **deployed-configuration artifact** (chunked, SHA-256-verified, newest-2 retention per cluster) so a driver node can **boot from cache when central SQL Server is unreachable** — `DriverHostActor` writes the cache after each successful apply (`IDeploymentArtifactCache` → `LocalDbDeploymentArtifactCache`, @@ -234,8 +237,27 @@ library's gRPC sync — **default-OFF, fail-closed bearer auth** (`LocalDbSyncAu on `LocalDb:SyncListenPort` (a dedicated Kestrel h2c listener) + `LocalDb:Replication:*`. On the docker-dev rig the **site-a pair** has replication on; central + site-b stay default-OFF (site-b is the pin). ApiKey via `${secret:}`/env, never committed (the rig's dev key is the committed-dev-secret -exception). See `docs/Configuration.md` (`LocalDb` section), `docs/Redundancy.md` (pair-local config -cache), and the runbook `docs/operations/2026-07-20-localdb-pair-replication.md`. +exception). + +**Phase 2** moved the **alarm store-and-forward buffer** off its standalone `alarm-historian.db` +and into the same database as `alarm_sf_events`, so a node that dies holding undelivered alarm +history no longer takes it to the grave. `SqliteStoreAndForwardSink` became +`LocalDbStoreAndForwardSink` (`Core.AlarmHistorian`), and the breaking config key +`AlarmHistorian:DatabasePath` was **removed** — it is still read, by `AlarmSfLegacyMigrator`, purely +to find a pre-consolidation file to copy across on first boot (renamed `.migrated` after the copy +commits). Because the buffer replicates, **only the node holding the Primary role drains it**: the +sink takes a `drainGate` delegate, Runtime supplies it from the `IRedundancyRoleView` singleton that +`DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, and a gated-off node reports the new +`HistorianDrainState.NotPrimary`. Delivery is therefore **at-least-once across a failover, by +design** (no dedup layer). Row ids are a SHA-256 of the event payload rather than a GUID, so the +same event accepted on both nodes — which happens in every boot window, since +`HistorianAdapterActor` default-writes while its role is unknown — converges to one row. On the +docker-dev rig the site-a pair and site-b both run `AlarmHistorian__Enabled=true` against a +deliberately unresolvable gateway endpoint, so the buffer is always a live, non-draining queue. + +See `docs/Configuration.md` (`LocalDb` section), `docs/Redundancy.md` (pair-local config +cache), `docs/AlarmHistorian.md`, and the runbook +`docs/operations/2026-07-20-localdb-pair-replication.md`. ## LDAP Authentication @@ -339,11 +361,13 @@ authorization uses the standard `AccessLevels.HistoryRead` bit set at materializ ### Alarm-history path (`AlarmHistorian` section) Alarm events are written through `GatewayAlarmHistorianWriter` (the gateway **`SendEvent`** path) behind -the durable **`SqliteStoreAndForwardSink`** — `AlarmHistorian:Enabled=true` swaps the `NullAlarmHistorianSink` -default for the SQLite store-and-forward queue, whose drain worker forwards batches to the gateway and uses -per-event outcomes to decide retry vs. dead-letter (never throws). The `AlarmHistorian` section carries -only the `Enabled` gate + the SQLite knobs (`DatabasePath`, `DrainIntervalSeconds`, `Capacity`, -`DeadLetterRetentionDays`, `BatchSize`, `MaxAttempts`) — the downstream gateway connection +the durable **`LocalDbStoreAndForwardSink`** — `AlarmHistorian:Enabled=true` swaps the `NullAlarmHistorianSink` +default for the store-and-forward queue, which buffers into the node's consolidated LocalDb as the +replicated `alarm_sf_events` table (see the LocalDb section) and whose drain worker forwards batches to +the gateway and uses per-event outcomes to decide retry vs. dead-letter (never throws). **Only the node +holding the Primary role drains** (a gated node reports `HistorianDrainState.NotPrimary`). The +`AlarmHistorian` section carries only the `Enabled` gate + the queue knobs (`DrainIntervalSeconds`, +`Capacity`, `DeadLetterRetentionDays`, `BatchSize`, `MaxAttempts`) — the downstream gateway connection (endpoint/key/TLS) is sourced from the `ServerHistorian` section. **Alarm-history `ReadEvents` requires the target gateway deployed with `RuntimeDb:EventReadsEnabled=true`** (the C2 SQL event-read workaround). diff --git a/Directory.Packages.props b/Directory.Packages.props index 82221614..9754f764 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -57,6 +57,7 @@ + diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 2d2d712d..b6b33a7d 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -328,6 +328,31 @@ services: # site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer. # Setting SyncListenPort makes Program.cs add a dedicated Http2-only Kestrel listener and # re-bind the primary HTTP port (an explicit Listen* otherwise discards ASPNETCORE_URLS). + # Alarm store-and-forward buffer, Phase 2. Enabled on BOTH halves of the pair so the + # buffer is a live, replicated table rather than an empty one — the live gate needs a real + # queue to watch converge, and needs to see that only the Primary drains it. + # + # There is no HistorianGateway on this rig, so the endpoint below is deliberately + # unresolvable: every drain attempt fails, which is exactly the historian-outage state the + # buffer exists for. Note the endpoint must still be a syntactically valid absolute http(s) + # URI or the host refuses to start — ServerHistorianOptionsValidator is consumer-gated, so + # AlarmHistorian:Enabled=true makes it required even while ServerHistorian:Enabled=false. + # + # MaxAttempts is raised far above the production default of 10. With a permanently + # unreachable gateway the default would dead-letter the whole queue about five minutes into + # the outage, turning a buffering test into a dead-letter test. + AlarmHistorian__Enabled: "true" + AlarmHistorian__MaxAttempts: "1000000" + ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}" + # Required whenever the section is consumed: the gateway client validates its own options at + # construction and throws on an empty key, so a keyless node crash-loops during Akka startup + # rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint + # above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via + # ServerHistorian__ApiKey from the environment. + ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}" + # Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER + # direction, so an http endpoint with the default UseTls=true crash-loops the host. + ServerHistorian__UseTls: "false" LocalDb__Path: "/app/data/otopcua-localdb.db" LocalDb__SyncListenPort: "9001" LocalDb__Replication__PeerAddress: "http://site-a-2:9001" @@ -365,6 +390,31 @@ services: # but does NOT dial (no PeerAddress). The replication stream is bidirectional, so a-1's # single dial carries both directions. Same key + MaxBatchSize as a-1 (byte-identical key # is mandatory — the interceptor fail-closes on a mismatch). + # Alarm store-and-forward buffer, Phase 2. Enabled on BOTH halves of the pair so the + # buffer is a live, replicated table rather than an empty one — the live gate needs a real + # queue to watch converge, and needs to see that only the Primary drains it. + # + # There is no HistorianGateway on this rig, so the endpoint below is deliberately + # unresolvable: every drain attempt fails, which is exactly the historian-outage state the + # buffer exists for. Note the endpoint must still be a syntactically valid absolute http(s) + # URI or the host refuses to start — ServerHistorianOptionsValidator is consumer-gated, so + # AlarmHistorian:Enabled=true makes it required even while ServerHistorian:Enabled=false. + # + # MaxAttempts is raised far above the production default of 10. With a permanently + # unreachable gateway the default would dead-letter the whole queue about five minutes into + # the outage, turning a buffering test into a dead-letter test. + AlarmHistorian__Enabled: "true" + AlarmHistorian__MaxAttempts: "1000000" + ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}" + # Required whenever the section is consumed: the gateway client validates its own options at + # construction and throws on an empty key, so a keyless node crash-loops during Akka startup + # rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint + # above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via + # ServerHistorian__ApiKey from the environment. + ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}" + # Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER + # direction, so an http endpoint with the default UseTls=true crash-loops the host. + ServerHistorian__UseTls: "false" LocalDb__Path: "/app/data/otopcua-localdb.db" LocalDb__SyncListenPort: "9001" LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key" @@ -397,6 +447,20 @@ services: # site-b is the default-OFF pin: it gets the pair-local cache but NO SyncListenPort and NO # replication config, so no sync listener binds and ISyncStatus stays disconnected/Healthy. # This is what the live gate's check 8 verifies — the cache works locally with replication off. + # Alarm store-and-forward with replication OFF — the other half of the site-b pin. The sink + # must work as a plain node-local buffer here, with no sync listener and no peer traffic. + AlarmHistorian__Enabled: "true" + AlarmHistorian__MaxAttempts: "1000000" + ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}" + # Required whenever the section is consumed: the gateway client validates its own options at + # construction and throws on an empty key, so a keyless node crash-loops during Akka startup + # rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint + # above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via + # ServerHistorian__ApiKey from the environment. + ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}" + # Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER + # direction, so an http endpoint with the default UseTls=true crash-loops the host. + ServerHistorian__UseTls: "false" LocalDb__Path: "/app/data/otopcua-localdb.db" ports: - "4844:4840" @@ -422,6 +486,20 @@ services: Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" # site-b default-OFF pin (see site-b-1): cache on, replication off. + # Alarm store-and-forward with replication OFF — the other half of the site-b pin. The sink + # must work as a plain node-local buffer here, with no sync listener and no peer traffic. + AlarmHistorian__Enabled: "true" + AlarmHistorian__MaxAttempts: "1000000" + ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}" + # Required whenever the section is consumed: the gateway client validates its own options at + # construction and throws on an empty key, so a keyless node crash-loops during Akka startup + # rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint + # above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via + # ServerHistorian__ApiKey from the environment. + ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}" + # Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER + # direction, so an http endpoint with the default UseTls=true crash-loops the host. + ServerHistorian__UseTls: "false" LocalDb__Path: "/app/data/otopcua-localdb.db" ports: - "4845:4840" diff --git a/docs/AlarmHistorian.md b/docs/AlarmHistorian.md index 212afe4c..26dc8efd 100644 --- a/docs/AlarmHistorian.md +++ b/docs/AlarmHistorian.md @@ -42,7 +42,7 @@ unless noted. - **`NullAlarmHistorianSink`** — the no-op default for tests and deployments that don't historize alarms. It is the default DI binding (registered in the Runtime's `AddOtOpcUaRuntime`); production overrides it with - `SqliteStoreAndForwardSink`. + `LocalDbStoreAndForwardSink`. - **`AlarmHistorianEvent`** ([`AlarmHistorianEvent.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmHistorianEvent.cs)) — the source-agnostic event record: `AlarmId`, `EquipmentPath` (UNS path, @@ -61,42 +61,84 @@ unless noted. - **`HistorianSinkStatus`** — diagnostic snapshot surfaced to the AdminUI and `/healthz`: `QueueDepth`, `DeadLetterDepth`, `LastDrainUtc`, `LastSuccessUtc`, `LastError`, `DrainState`, and `EvictedCount`. -- **`HistorianDrainState`** — `Disabled` / `Idle` / `Draining` / `BackingOff`. +- **`HistorianDrainState`** — `Disabled` / `Idle` / `Draining` / `BackingOff` / + **`NotPrimary`** (ticking, but leaving the replicated queue for the node that + holds the Primary role). --- -## SqliteStoreAndForwardSink +## LocalDbStoreAndForwardSink -[`SqliteStoreAndForwardSink.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs) -is the production `IAlarmHistorianSink`. Construction takes a SQLite database -path, an `IAlarmHistorianWriter`, a logger, and optional `batchSize` (default -100), `capacity` (default 1,000,000), `deadLetterRetention` (default 30 days), -and a test clock. +[`LocalDbStoreAndForwardSink.cs`](../src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs) +is the production `IAlarmHistorianSink`. Construction takes the node's +`ILocalDb`, an `IAlarmHistorianWriter`, a logger, and optional `batchSize` +(default 100), `capacity` (default 1,000,000), `deadLetterRetention` (default 30 +days), a test clock, and a **`drainGate`**. + +### The drain gate + +`drainGate` is a `Func` consulted at the top of every tick; `false` skips +the tick entirely, leaving the queue untouched and reporting +`DrainState = NotPrimary`. + +It exists because the queue **replicates**. The Secondary holds a full copy of +the Primary's rows, and an ungated drain there would re-deliver every event, +continuously — not just at a failover. Runtime supplies the gate from +`IRedundancyRoleView`, a singleton `DriverHostActor` publishes its +`PrimaryGatePolicy` verdict to on every redundancy snapshot, so the drain agrees +with the inbound-write and native-ack gates by construction. + +Two postures are deliberate: + +- **Unset (the default) means drain.** So does an unpublished view. A deployment + that runs no redundancy never publishes a role, and defaulting closed would + silently stop its alarm history forever. +- **A gate that throws means "not now", never permission.** Draining on a gate + fault would put a pair into dual delivery exactly when its redundancy signal is + sick. ### Queue table -The sink owns one SQLite table (created on construction, WAL journal mode): +The sink owns one table in the node's consolidated LocalDb — created and +registered for replication by `LocalDbSetup.OnReady`, before any consumer can +resolve the sink: ```sql -CREATE TABLE Queue ( - RowId INTEGER PRIMARY KEY AUTOINCREMENT, - AlarmId TEXT NOT NULL, - EnqueuedUtc TEXT NOT NULL, - PayloadJson TEXT NOT NULL, -- JSON-serialized AlarmHistorianEvent - AttemptCount INTEGER NOT NULL DEFAULT 0, - LastAttemptUtc TEXT NULL, - LastError TEXT NULL, - DeadLettered INTEGER NOT NULL DEFAULT 0 +CREATE TABLE alarm_sf_events ( + id TEXT NOT NULL PRIMARY KEY, -- SHA-256 of payload_json + alarm_id TEXT NOT NULL, + enqueued_at_utc TEXT NOT NULL, + payload_json TEXT NOT NULL, -- JSON-serialized AlarmHistorianEvent + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_utc TEXT NULL, + last_error TEXT NULL, + dead_lettered INTEGER NOT NULL DEFAULT 0 ); -CREATE INDEX IX_Queue_Drain ON Queue (DeadLettered, RowId); +CREATE INDEX ix_alarm_sf_events_drain + ON alarm_sf_events (dead_lettered, enqueued_at_utc, id); ``` -`EnqueueAsync` does a single `INSERT` on the hot path. To avoid a -`SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory non-dead-lettered -row counter (seeded at startup, kept current by every mutation, and re-synced -from storage every 10,000 enqueues to defend against drift). SQLite writer -contention is handled via `PRAGMA busy_timeout=5000` + WAL so an enqueue/drain -collision waits out the file lock instead of failing fast. +**The primary key is a hash of the payload, not an autoincrement rowid.** Two +reasons. Replication converges last-writer-wins *per primary key*, so two nodes +independently allocating rowid 7 to different alarms would silently overwrite one +another. And an equal-payload key makes the same event arriving twice collapse +into one row — which happens for real, because `HistorianAdapterActor` +default-writes while its redundancy role is unknown and both nodes of a pair +therefore accept the same fanned transition in every boot window. Two genuinely +distinct events cannot collide: `AlarmHistorianEvent` carries a full-precision +timestamp alongside the alarm id, kind, message and user. + +Ordering follows from that: the drain reads in `enqueued_at_utc` order (with `id` +as a tiebreak so the ordering is total), because a hashed key carries no +insertion sequence. + +`EnqueueAsync` does a single `INSERT … ON CONFLICT DO NOTHING` on the hot path. +To avoid a `SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory +non-dead-lettered row counter (seeded at startup, kept current by every mutation, +and re-synced from storage every 10,000 enqueues to defend against drift — now +doubly warranted, since replication applies the peer's rows straight into the +table where no local code path can observe them). Pragmas and connection +lifetime belong to LocalDb; the sink no longer manages either. ### Drain worker @@ -104,13 +146,17 @@ collision waits out the file lock instead of failing fast. `System.Threading.Timer`** (not started automatically — tests drive `DrainOnceAsync` deterministically). Each tick: +0. Consults the drain gate. A closed gate returns immediately, leaving the queue + untouched and `DrainState = NotPrimary`. 1. Purges aged dead-lettered rows past the retention window. -2. Reads up to `batchSize` non-dead-lettered rows in `RowId` order. +2. Reads up to `batchSize` non-dead-lettered rows in `enqueued_at_utc, id` order. 3. Rows with un-deserializable payloads are dead-lettered immediately (by their - own `RowId`) so they can't stall the queue head. + own `id`) so they can't stall the queue head. 4. The remaining batch is handed to `IAlarmHistorianWriter.WriteBatchAsync`, and - each outcome is applied in one transaction: `Ack` deletes the row, - `PermanentFail` flips its `DeadLettered` flag, `RetryPlease` bumps its attempt + each outcome is applied in one transaction: `Ack` deletes the row (the delete + replicates as a tombstone, so the peer drops its copy and a promoted standby + cannot re-send it), `PermanentFail` flips its `dead_lettered` flag, + `RetryPlease` bumps its attempt count and leaves it queued. A row whose `AttemptCount` has reached the configured **`MaxAttempts`** cap (default 10) is dead-lettered automatically on the next drain tick rather than retried — this breaks infinite retry loops for poison events whose @@ -131,7 +177,7 @@ an unobserved task exception. **The durability guarantee is bounded by `capacity` (default 1,000,000 rows).** When the non-dead-lettered queue reaches capacity, `EnqueueAsync` evicts the -oldest non-dead-lettered rows (oldest `RowId` first) to make room, logs a WARN, +oldest non-dead-lettered rows (oldest `enqueued_at_utc` first) to make room, logs a WARN, and increments `HistorianSinkStatus.EvictedCount`. Under a sustained historian outage, accepted alarm events can therefore be dropped before delivery. A non-zero `EvictedCount` is a data-loss signal that requires operator attention — @@ -140,7 +186,7 @@ it surfaces silent loss without log scraping. ### Dead-letter + operator recovery `PermanentFail` and corrupt-payload rows are retained in-place with -`DeadLettered = 1` for the retention window (default 30 days) so operators can +`dead_lettered = 1` for the retention window (default 30 days) so operators can inspect them before the sweeper purges them. `RetryDeadLettered()` is the operator action (from the AdminUI) that clears the dead-letter flag and attempt count on every dead-lettered row, returning them to the regular queue with a @@ -173,17 +219,28 @@ the `alerts` topic (future). The real sink is opt-in via the `AlarmHistorian` section of `appsettings.json`. When `Enabled` is `false` (the default), `AddAlarmHistorian` registers `NullAlarmHistorianSink` and the feature is dormant. When `Enabled` is `true`, -`AddAlarmHistorian` constructs `SqliteStoreAndForwardSink` and registers +`AddAlarmHistorian` constructs `LocalDbStoreAndForwardSink` and registers `GatewayAlarmHistorianWriter` as the `IAlarmHistorianWriter`. This section carries -**only** the `Enabled` gate + the SQLite store-and-forward knobs — the downstream +**only** the `Enabled` gate + the store-and-forward knobs — the downstream gateway connection (endpoint / key / TLS) is sourced from the `ServerHistorian` -section (see [Historian.md](Historian.md)). +section (see [Historian.md](Historian.md)), and the queue's storage location is the +node's consolidated `LocalDb:Path` database (see +[Configuration.md](Configuration.md)). + +> **Where the queue lives (changed).** The buffer is a table — `alarm_sf_events` — in the node's +> consolidated LocalDb, not a standalone `alarm-historian.db`. That is what lets it **replicate to +> the redundant pair peer**, so undelivered alarm history survives losing the node holding it. +> Two consequences follow, both covered in the +> [pair-replication runbook](operations/2026-07-20-localdb-pair-replication.md): +> **only the Primary drains** (the Secondary holds a full replica and would otherwise re-send +> everything), and **delivery is at-least-once across a failover** by design. On first boot after +> the upgrade, `AlarmSfLegacyMigrator` copies any existing `alarm-historian.db` across and renames +> it `.migrated`. ```json { "AlarmHistorian": { "Enabled": true, - "DatabasePath": "C:\\ProgramData\\OtOpcUa\\alarmhistorian.db", "BatchSize": 100, "DrainIntervalSeconds": 5, "Capacity": 1000000, @@ -194,8 +251,7 @@ section (see [Historian.md](Historian.md)). | Key | Type | Default | Description | |---|---|---|---| -| `Enabled` | bool | `false` | Enable the SQLite store-and-forward sink (drains to the HistorianGateway `SendEvent` path). `false` → `NullAlarmHistorianSink`. | -| `DatabasePath` | string | `alarm-historian.db` | Path to the SQLite queue file. Created on first use (WAL mode). Set an **absolute** path in production. | +| `Enabled` | bool | `false` | Enable the durable store-and-forward sink (drains to the HistorianGateway `SendEvent` path). `false` → `NullAlarmHistorianSink`. Requires LocalDb to be registered — an enabled sink with no `ILocalDb` fails at resolution rather than silently discarding events. | | `BatchSize` | int | `100` | Max rows per drain cycle handed to `IAlarmHistorianWriter.WriteBatchAsync`. | | `DrainIntervalSeconds` | int | `5` | Seconds between drain-worker ticks. | | `Capacity` | long | `1000000` | Max queued rows before the sink evicts the oldest (data-loss signal via `EvictedCount`). | @@ -207,6 +263,15 @@ section (see [Historian.md](Historian.md)). > `RuntimeDb:EventReadsEnabled=true`. The old Wonderware connection keys (`SharedSecret` / > `AlarmHistorian:Host`/`Port`/`UseTls`/`ServerCertThumbprint`) were pruned. +> **`DatabasePath` was removed.** The queue no longer owns a file. The key is still *read*, by +> `AlarmSfLegacyMigrator`, purely to locate a pre-consolidation `alarm-historian.db` to migrate — +> so leave it in place through the upgrade, then delete it once every node has an +> `alarm-historian.db.migrated` sidecar. A leftover value configures nothing. + +> **`DrainState` gained `NotPrimary`.** A gated-off Secondary reports it instead of `Idle`, so a +> rising queue depth there is distinguishable from a stalled drain. Both nodes reporting it means +> nobody is draining — see the runbook. + > Dev and docker-dev deployments leave `Enabled` unset (defaults to `false`) so alarm transitions historize to nowhere unless a HistorianGateway is configured. --- @@ -221,3 +286,5 @@ section (see [Historian.md](Historian.md)). - [ScriptedAlarms.md](ScriptedAlarms.md) — the scripted-alarm engine that emits most events into this sink. - [ServiceHosting.md](ServiceHosting.md) — the external HistorianGateway backend. +- [operations/2026-07-20-localdb-pair-replication.md](operations/2026-07-20-localdb-pair-replication.md) + — where the queue lives, the Primary-only drain, and the one-time legacy migration. diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 4f00551d..ce5e3c63 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -365,12 +365,16 @@ AB CIP ALMD) route to AVEVA Historian via the HistorianGateway: - `IAlarmHistorianSink` is the DI-registered intake contract. The default binding is `NullAlarmHistorianSink` (registered in `ServiceCollectionExtensions.AddOtOpcUaRuntime`). Production - deployments override it with `SqliteStoreAndForwardSink` wrapping + deployments override it with `LocalDbStoreAndForwardSink` wrapping `GatewayAlarmHistorianWriter` (the HistorianGateway `SendEvent` path) — see [ServiceHosting.md](ServiceHosting.md) for the HistorianGateway setup. -- `SqliteStoreAndForwardSink` queues each transition to a local - SQLite database and drains in the background via an - `IAlarmHistorianWriter`. **The durability guarantee is bounded**: the +- `LocalDbStoreAndForwardSink` queues each transition into the node's + consolidated LocalDb — where it **replicates to the redundant pair peer**, + so undelivered alarm history survives losing the node holding it — and + drains in the background via an `IAlarmHistorianWriter`. **Only the node + holding the Primary role drains**, since the peer holds a full replica; + delivery is consequently at-least-once across a failover, by design. See + [AlarmHistorian.md](AlarmHistorian.md). **The durability guarantee is bounded**: the queue capacity defaults to 1,000,000 rows; under a sustained historian outage, older non-dead-lettered rows are evicted (oldest first) to make room for new events. The `HistorianSinkStatus.EvictedCount` @@ -379,8 +383,9 @@ AB CIP ALMD) route to AVEVA Historian via the HistorianGateway: capacity, and dead-letter retention are tunable via the `AlarmHistorian` config section (`DrainIntervalSeconds`, `Capacity`, `DeadLetterRetentionDays`); `AlarmHistorianOptions.Validate()` logs a - startup warning for an empty `SharedSecret`, a relative `DatabasePath`, - or a non-positive knob. + startup warning for a non-positive knob. (It no longer warns about + `SharedSecret` or `DatabasePath` — both keys are gone: the connection + comes from `ServerHistorian`, and the queue lives in the LocalDb.) - `HistorianAdapterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs`) subscribes to the cluster `alerts` DPS topic, translates each diff --git a/docs/Configuration.md b/docs/Configuration.md index 5f01bfdc..c4b1ecb3 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -136,8 +136,9 @@ The historian backend is the external **`ZB.MOM.WW.HistorianGateway`** sidecar, `ZB.MOM.WW.HistorianGateway.Client` gRPC package (the retired Wonderware TCP sidecar is documented at [`docs/drivers/Historian.Wonderware.md`](drivers/Historian.Wonderware.md)). The OtOpcUa host reads three appsettings sections — `ServerHistorian` (read path + gateway connection), `ContinuousHistorization` -(FasterLog outbox + recorder draining to `WriteLiveValues`), and `AlarmHistorian` (SQLite store-and-forward -alarm sink draining to `SendEvent`). The gateway connection (endpoint / key / TLS) lives **only** in +(FasterLog outbox + recorder draining to `WriteLiveValues`), and `AlarmHistorian` (the store-and-forward +alarm sink draining to `SendEvent`; its buffer lives in the node's `LocalDb:Path` database, not a +file of its own). The gateway connection (endpoint / key / TLS) lives **only** in `ServerHistorian`; the other two sections source it from there. The gateway API key is supplied via the environment variable **`ServerHistorian__ApiKey`** — never committed diff --git a/docs/README.md b/docs/README.md index c5df5b74..6c320064 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,7 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess | [Subscriptions.md](v1/Subscriptions.md) | Monitored items → `ISubscribable` + per-driver subscription refcount (v1 archive) | | [AlarmTracking.md](AlarmTracking.md) | `IAlarmSource` + `AlarmSurfaceInvoker` + OPC UA alarm conditions — native Galaxy alarms end-to-end (live) | | [AlarmTracking.md](v1/AlarmTracking.md) | Original alarm-tracking write-up (v1 archive) | -| [AlarmHistorian.md](AlarmHistorian.md) | `Core.AlarmHistorian` store-and-forward SQLite sink — `SqliteStoreAndForwardSink`, `IAlarmHistorianWriter`, dead-letter/retry/eviction | +| [AlarmHistorian.md](AlarmHistorian.md) | `Core.AlarmHistorian` store-and-forward sink — `LocalDbStoreAndForwardSink`, the replicated `alarm_sf_events` buffer + Primary-only drain, `IAlarmHistorianWriter`, dead-letter/retry/eviction | | [DataTypeMapping.md](v1/DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types (v1 archive — live mapping is in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs`) | | [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` | | [HistoricalDataAccess.md](v1/HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability (v1 archive) | diff --git a/docs/Redundancy.md b/docs/Redundancy.md index 6692bed9..a0b5f3ad 100644 --- a/docs/Redundancy.md +++ b/docs/Redundancy.md @@ -69,6 +69,23 @@ Roles come from `RedundancyStateActor.BuildSnapshot`: a node with the `driver` role is `Primary` when it holds the `driver` role-leader lease, otherwise `Secondary`; a node without the `driver` role is `Detached`. +> **KNOWN LIMITATION — the election is per *Akka* cluster, not per application `Cluster`.** +> `RoleLeader("driver")` yields exactly **one** Primary across the whole Akka cluster. A fleet that +> runs several application clusters (each with its own redundant pair) inside one Akka cluster — +> which is what `docker-dev/docker-compose.yml` models, with MAIN, SITE-A and SITE-B — therefore has +> one Primary among *all* its driver nodes, not one per pair. Every consumer of the role inherits +> this: `ServiceLevel`, the `alerts` emit gate, and the inbound device-write gate below. +> +> The unit of redundancy everywhere else in the product is the application `Cluster` (`ClusterId`) — +> it owns drivers, devices and `ClusterNode` rows, and the LocalDb deployment-artifact cache is +> already keyed by it *so that a pair shares one entry*. Scoping the election the same way is the +> fix; `RedundancyStateActor` is an admin-role singleton in the same assembly as +> `AdminOperationsActor`, which already reads and groups `ClusterNodes` by cluster. +> +> Surfaced by the LocalDb Phase 2 live gate, where it stopped the alarm-history drain on every node +> in the fleet — see `docs/plans/2026-07-20-localdb-phase2-live-gate.md`. The alarm drain no longer +> depends on this (it defers only to a node that shares its queue), but the other three gates still do. + ## Data flow ``` @@ -221,15 +238,19 @@ Under warm/hot redundancy both cluster nodes run `ScriptedAlarmHostActor` and ev - **`alerts` topic emission** — `ScriptedAlarmHostActor` and `DriverHostActor.ForwardNativeAlarm` subscribe to the `redundancy-state` DPS topic and cache the local node's `RedundancyRole`, then gate the cluster `alerts` publish through `PrimaryGatePolicy` (table above). The OPC UA condition-node write and inbound ack/shelve command processing remain **ungated** on both nodes so the secondary is always ready to serve clients after a failover. - **`HistorianAdapterActor` historization** — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to the `alerts` DPS topic and translates each `AlarmTransitionEvent` → `AlarmHistorianEvent` before enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size. +- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md). Net effect: each alarm transition appears **once** on `/alerts` and would historize once, not once per node. See [ScriptedAlarms.md](ScriptedAlarms.md) and [AlarmTracking.md](AlarmTracking.md) for the scripted-alarm engine internals. -## Pair-local config cache (LocalDb — Phase 1) +## Pair-local store (LocalDb — Phases 1 + 2) Independently of the ServiceLevel machinery above, every **driver-role** node keeps a consolidated -[`ZB.MOM.WW.LocalDb`](../CLAUDE.md) SQLite database that caches the **deployed-configuration +[`ZB.MOM.WW.LocalDb`](../CLAUDE.md) SQLite database. Phase 2 added the **alarm store-and-forward +buffer** (`alarm_sf_events`) to it alongside the config cache, so a node that dies holding +undelivered alarm history no longer takes it to the grave — see the Primary-only drain note above +and [AlarmHistorian.md](AlarmHistorian.md). Phase 1 caches the **deployed-configuration artifact** (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver node restarting into a **central-SQL-Server outage** would come up with no configuration at all. With the cache, it **boots from the last artifact it applied** instead, and logs a running-from-cache diff --git a/docs/drivers/Historian.Wonderware.md b/docs/drivers/Historian.Wonderware.md index 839ca7fc..ebf18c0b 100644 --- a/docs/drivers/Historian.Wonderware.md +++ b/docs/drivers/Historian.Wonderware.md @@ -13,7 +13,7 @@ OtOpcUa now consumes the **`ZB.MOM.WW.HistorianGateway`** sidecar through the Gi - **HistoryRead** → `GatewayHistorianDataSource` over the `ServerHistorian` appsettings section. - **Alarm history** → `GatewayAlarmHistorianWriter` (the gateway `SendEvent` path) behind the durable - `SqliteStoreAndForwardSink`; alarm-history `ReadEvents` needs the gateway running + `LocalDbStoreAndForwardSink`; alarm-history `ReadEvents` needs the gateway running `RuntimeDb:EventReadsEnabled=true`. - **Continuous historization** → a crash-safe FasterLog outbox + `ContinuousHistorizationRecorder` draining to the gateway's `WriteLiveValues` (`ContinuousHistorization` section); needs the gateway diff --git a/docs/operations/2026-07-20-localdb-pair-replication.md b/docs/operations/2026-07-20-localdb-pair-replication.md index 78422482..8b7fc525 100644 --- a/docs/operations/2026-07-20-localdb-pair-replication.md +++ b/docs/operations/2026-07-20-localdb-pair-replication.md @@ -1,18 +1,20 @@ # LocalDb pair replication — operations runbook -> **Phase 1 scope.** Every driver-role OtOpcUa node keeps a consolidated -> [`ZB.MOM.WW.LocalDb`](../../CLAUDE.md) SQLite database. Today it caches exactly one thing: the -> **deployed-configuration artifact**, chunked, so a node can **boot from cache when central SQL -> Server is unreachable**. That cache can *optionally* replicate to the node's redundant pair peer -> over the library's gRPC sync. **Replication is default-OFF and fail-closed.** This runbook covers -> enabling it, the operational rules that keep a pair converging, and the DB-inspection safety -> rules. +> **Scope.** Every driver-role OtOpcUa node keeps a consolidated +> [`ZB.MOM.WW.LocalDb`](../../CLAUDE.md) SQLite database. It holds two things: the +> **deployed-configuration artifact** (chunked, Phase 1) so a node can **boot from cache when +> central SQL Server is unreachable**, and the **alarm store-and-forward buffer** (Phase 2) so a +> node that dies holding undelivered alarm history does not take it to the grave. Both can +> *optionally* replicate to the node's redundant pair peer over the library's gRPC sync. +> **Replication is default-OFF and fail-closed.** This runbook covers enabling it, the operational +> rules that keep a pair converging, and the DB-inspection safety rules. ## What replicates, and what it buys you -- **Replicated tables:** `deployment_artifacts` (the artifact, split into base64 chunks) and - `deployment_pointer` (one current-deployment pointer per cluster). Registered — in this order, - after the DDL — by `LocalDbSetup.OnReady`. +- **Replicated tables:** `deployment_artifacts` (the artifact, split into base64 chunks), + `deployment_pointer` (one current-deployment pointer per cluster), and `alarm_sf_events` (the + alarm store-and-forward buffer). Registered — in this order, after the DDL — by + `LocalDbSetup.OnReady`. - **The payoff:** in a redundant driver pair, *either* node can be the one that applied the latest deploy and wrote the cache. With replication on, the peer holds a byte-identical copy. So a node that restarts into a central-SQL outage can boot the current config **even if it never applied @@ -22,6 +24,47 @@ deployed. Retention (newest-2 deployments per cluster) prunes on one node and the deletes replicate as tombstones. +## Alarm store-and-forward — what replication changes + +Phase 2 moved the alarm buffer out of its own `alarm-historian.db` and into this database. Three +consequences an operator needs to know: + +- **Only the Primary drains.** The buffer replicates, so the Secondary holds a full copy of the + Primary's queue. Its drain worker is gated on the same Primary decision the inbound-write and + native-ack gates use, and reports `DrainState = NotPrimary`. **A rising queue depth on a + Secondary is correct.** A rising queue depth on *both* nodes is not — see below. +- **Delivery is at-least-once across a failover, by design.** Rows the old Primary delivered + moments before it died may not have had their deletes replicated yet, so the new Primary will + re-send them. This is accepted, not a defect: a duplicate alarm-history row is recoverable, a + missing one is not. There is deliberately no dedup layer. +- **The buffer is bounded.** `AlarmHistorian:Capacity` (1,000,000 by default) still evicts the + oldest undelivered rows when the historian has been unreachable long enough. Watch + `EvictedCount` — non-zero means accepted alarm events were dropped before reaching the historian. + +### If BOTH nodes report `NotPrimary` + +Nobody is draining, and the queue is filling toward the capacity ceiling on both. The gate +default-DENIES while the redundancy role is unknown *and* a driver peer exists, so this is what a +redundancy snapshot that never names either node looks like — the identity-mismatch shape +`DriverHostActor` also warns about once ("redundancy snapshot omitted this node"). Check node +identity (`Cluster:PublicHostname` / `Cluster:Port` skew) before suspecting the sink. + +### One-time migration from `alarm-historian.db` + +On first boot after the upgrade, `AlarmSfLegacyMigrator` copies any pre-existing +`alarm-historian.db` into `alarm_sf_events` and renames the file to `alarm-historian.db.migrated`. + +- **Both nodes of a pair migrate independently**, and their files overlap — row ids are derived + from the event payload, so the same event on both nodes converges to one row rather than + duplicating. The new Primary drains the union. +- **The rename happens only after the copy commits.** A failure fails host startup with the legacy + file untouched; the `.migrated` sidecar is what makes a later boot a no-op. +- **A file whose shape is unrecognised is left alone** — not renamed, not copied — so it stays + available for inspection. +- The `AlarmHistorian:DatabasePath` key is **removed**. It is still *read* by the migrator, to + find the legacy file; it no longer configures anything. Delete it from appsettings once the + `.migrated` sidecar exists on every node. + ## Enabling replication on a pair Replication has two knobs, both under `LocalDb`: @@ -127,10 +170,22 @@ FROM __localdb_row_version WHERE table_name = 'deployment_pointer' ORDER BY pk_j -- Replication backlog (should drain to 0 when a pair is caught up) SELECT COUNT(*) FROM __localdb_oplog; + +-- Undelivered alarm history, and how much of it has given up +SELECT dead_lettered, COUNT(*) FROM alarm_sf_events GROUP BY dead_lettered; + +-- Why the dead-lettered rows died +SELECT alarm_id, attempt_count, last_attempt_utc, last_error +FROM alarm_sf_events WHERE dead_lettered = 1 ORDER BY last_attempt_utc DESC LIMIT 20; + +-- Convergence check for the buffer: identical dumps mean the peer holds the ORIGIN-stamped rows +SELECT pk_json, hlc, node_id, is_tombstone +FROM __localdb_row_version WHERE table_name = 'alarm_sf_events' ORDER BY pk_json; ``` ## See also - [`docs/Redundancy.md`](../Redundancy.md) — the pair-local config cache section. - [`docs/Configuration.md`](../Configuration.md) — the `LocalDb` appsettings section. +- [`docs/AlarmHistorian.md`](../AlarmHistorian.md) — the alarm sink, its knobs, and the drain. - The two-node convergence harness: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/`. diff --git a/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json b/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json index 7cbfdc9a..2eb2139a 100644 --- a/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json +++ b/docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json @@ -1,15 +1,86 @@ { "planPath": "docs/plans/2026-07-20-localdb-adoption-phase2.md", "tasks": [ - { "id": 0, "subject": "Task 0: Recon — sink schema, seam, drain lifecycle, role-view bridge (STOP conditions)", "status": "pending" }, - { "id": 1, "subject": "Task 1: alarm_sf_events schema + registration (+ exact-set pin update)", "status": "pending", "blockedBy": [0] }, - { "id": 2, "subject": "Task 2: Rewire sink onto ILocalDb + delete bespoke file management (cutover 1/2)", "status": "pending", "blockedBy": [1] }, - { "id": 3, "subject": "Task 3: Primary-gated drain via PrimaryGatePolicy (cutover 2/2 — may co-commit with Task 2)", "status": "pending", "blockedBy": [2] }, - { "id": 4, "subject": "Task 4: One-time alarm-historian.db legacy migrator", "status": "pending", "blockedBy": [3] }, - { "id": 5, "subject": "Task 5: Convergence + failover scenarios in the pair harness (+ positive control)", "status": "pending", "blockedBy": [3] }, - { "id": 6, "subject": "Task 6: Rig config + docs", "status": "pending", "blockedBy": [3] }, - { "id": 7, "subject": "Task 7: DoD sweep (offline) — STOP and report after this", "status": "pending", "blockedBy": [4, 5, 6] }, - { "id": 8, "subject": "Task 8: Live gate on the docker-dev rig (needs explicit user go-ahead)", "status": "pending", "blockedBy": [7] } + { + "id": 0, + "subject": "Task 0: Recon \u2014 sink schema, seam, drain lifecycle, role-view bridge (STOP conditions)", + "status": "completed", + "note": "STOP condition does NOT fire (no BLOB; PayloadJson TEXT). Recon doc: docs/plans/2026-07-20-localdb-phase2-recon.md. Key finding: the drain worker is an internal Timer inside the sink (not a hosted service/actor), so the gate is a Func ctor param; and HistorianAdapterActor already primary-gates ENQUEUE with a different policy (ShouldHistorize) - which is why today's ungated drain is safe and why replication breaks it. Deviations D-1..D-4 recorded." + }, + { + "id": 1, + "subject": "Task 1: alarm_sf_events schema + registration (+ exact-set pin update)", + "status": "completed", + "blockedBy": [ + 0 + ], + "note": "alarm_sf_events created in AlarmSfSchema (Core.AlarmHistorian) + registered third in LocalDbSetup.OnReady. Exact-set pin updated to 3 tables. DEVIATION D-5: kept the legacy delete-on-ack + dead_lettered flag + last_error rather than the plan's status column (no sweeper for 'delivered' rows; last_error is the only record of why a row died). Drain ORDER BY moves to (enqueued_at_utc, id)." + }, + { + "id": 2, + "subject": "Task 2: Rewire sink onto ILocalDb + delete bespoke file management (cutover 1/2)", + "status": "completed", + "blockedBy": [ + 1 + ], + "note": "Sink rewritten as LocalDbStoreAndForwardSink over ILocalDb; bespoke file/pragma/schema management deleted with the old class. AlarmHistorian:DatabasePath removed (breaking config key). Ids are a deterministic payload hash (D-1), not GUIDs." + }, + { + "id": 3, + "subject": "Task 3: Primary-gated drain via PrimaryGatePolicy (cutover 2/2 \u2014 may co-commit with Task 2)", + "status": "completed", + "blockedBy": [ + 2 + ], + "note": "Drain gated on IRedundancyRoleView, a singleton DriverHostActor publishes PrimaryGatePolicy's verdict to on every snapshot. Fails closed on a throwing gate; seeded OPEN so a non-redundant deployment is never silently stopped. New HistorianDrainState.NotPrimary + transition-logged (D-2). Landed with Task 2 as one commit (D-3)." + }, + { + "id": 4, + "subject": "Task 4: One-time alarm-historian.db legacy migrator", + "status": "completed", + "blockedBy": [ + 3 + ], + "note": "AlarmSfLegacyMigrator runs LAST in OnReady (which now takes IConfiguration - no skip-migration overload exists). DEVIATION D-6: ids are the payload hash (AlarmSfSchema.DeriveId, lifted out of the sink) rather than mig-{node}-{legacyId} - a warm pair's two legacy files OVERLAP, and node-prefixing would carry that duplication forward forever. DEVIATION: tests live in Host.IntegrationTests; the plan's Host.Tests project does not exist." + }, + { + "id": 5, + "subject": "Task 5: Convergence + failover scenarios in the pair harness (+ positive control)", + "status": "completed", + "blockedBy": [ + 3 + ], + "note": "4 scenarios green. POSITIVE CONTROL RUN (RegisterReplicated(alarm_sf_events) commented out): 3/4 went red immediately; the 4th (same-event-on-both-nodes) passed VACUOUSLY - with replication off each node trivially held its own single row. Strengthened by enqueuing a second distinct event on B and asserting BOTH nodes hold 2; it then went red under the control too. Control restored, all 4 green. Also fixed a SECOND exact-set pin the plan predicted: LocalDbWiringTests." + }, + { + "id": 6, + "subject": "Task 6: Rig config + docs", + "status": "completed", + "blockedBy": [ + 3 + ], + "note": "Rig: AlarmHistorian__Enabled=true on site-a-1/2 AND site-b-1/2, with MaxAttempts raised to 1e6 (D-4: the default 10 would dead-letter the queue ~5min into the rig's permanent gateway outage) and a deliberately unresolvable ServerHistorian__Endpoint. NOTE: the endpoint is REQUIRED - ServerHistorianOptionsValidator is consumer-gated on AlarmHistorian:Enabled, so an empty one fails host start. Docs: runbook, AlarmHistorian.md, AlarmTracking.md, Configuration.md, Redundancy.md, docs/README.md, CLAUDE.md." + }, + { + "id": 7, + "subject": "Task 7: DoD sweep (offline) \u2014 STOP and report after this", + "status": "completed", + "blockedBy": [ + 4, + 5, + 6 + ], + "note": "Build: 0 errors solution-wide; 0 warnings from every project this branch touches (the ~816 solution-wide warnings are pre-existing xUnit1051/OTOPCUA0001/CS86xx in untouched driver + client test projects). GREPS FOUND REAL DRIFT Task 6 missed: 8 live sites still named the deleted SqliteStoreAndForwardSink - CLAUDE.md, the AdminUI /alarms/historian panel text (user-visible), HistorianAdapterActor (a that did NOT warn), Runtime + Host + 2 Driver.Historian.Gateway doc comments, 1 gateway test comment, docs/drivers/Historian.Wonderware.md; plus docs/AlarmTracking.md still claimed a Validate() startup warning for a relative DatabasePath (that branch is gone). All fixed. Code refs to AlarmHistorian:DatabasePath now reduce to exactly two intentional ones: AlarmSfLegacyMigrator.LegacyPathKey and its test. No 'new SqliteConnection' in Core.AlarmHistorian. Both exact-set pins assert the 3-table set, both directions. Guard-deletion evidence (both vacuous passes + the pin inventory) consolidated into the recon doc." + }, + { + "id": 8, + "subject": "Task 8: Live gate on the docker-dev rig (needs explicit user go-ahead)", + "status": "completed", + "blockedBy": [ + 7 + ], + "note": "Gate doc: docs/plans/2026-07-20-localdb-phase2-live-gate.md. Checks 1/2/5/6 PASS; checks 3+4 NOT SATISFIED (see below). FOUR production defects found + fixed, three of which crash-looped every driver node before check 1 could run: (1) empty ServerHistorian:ApiKey crashes the host - the validator had explicitly classified it as 'degrades', which is false because the gateway client validates its own options at construction; (2) UseTls vs endpoint scheme mismatch crashes likewise, both directions; (3) plaintext h2c was UNREACHABLE - the adapter forwarded TLS-only options unconditionally so every documented http:// deployment crashed; (4) THE BLOCKER - the drain gate deferred to a CLUSTER-WIDE elected Primary while the queue is PAIR-LOCAL. On the rig that Primary is central-1 (carries the driver Akka role, replicates nobody, runs no historian), so ALL FOUR driver nodes suspended their drains including unpaired site-b: silent permanent loss where pre-Phase-2 drained fine. Fixed in 3 layers (separate ShouldDrainAlarmHistory policy; peer-host matching in DriverHostActor; gate short-circuited entirely when replication is unconfigured, testing BOTH PeerAddress and SyncListenPort since only the dialer sets the former). Also fixed a THIRD vacuous test: AwaitAssert on the seeded-open value passed with the guard deleted; now asserts the sequence of PUBLISHED values via a recording view. Migration evidence: 11 legacy rows across two overlapping files -> exactly 9 identical rows on both nodes, proving D-6's payload-hash identity live. OPEN DESIGN FORK (in the gate doc): a pair cannot identify its own Primary, so both halves drain - safe (no loss, duplicates only) but the gate's de-dup benefit is unrealised." + } ], - "lastUpdated": "2026-07-20T00:00:00Z" + "lastUpdated": "2026-07-21T00:00:00Z" } diff --git a/docs/plans/2026-07-20-localdb-phase2-live-gate.md b/docs/plans/2026-07-20-localdb-phase2-live-gate.md new file mode 100644 index 00000000..8328cca2 --- /dev/null +++ b/docs/plans/2026-07-20-localdb-phase2-live-gate.md @@ -0,0 +1,180 @@ +# LocalDb Phase 2 — live gate (docker-dev rig) + +> Task 8 of `2026-07-20-localdb-adoption-phase2.md`. Evidence log. +> Rig: local `docker-dev/docker-compose.yml` — 2 central + site-a pair (replication ON) + site-b pair +> (default-OFF), one Akka cluster, one shared SQL. +> +> **Safety rules followed** (same as the Phase 1 gate): all DB inspection via `docker cp` of the +> `db`/`-wal`/`-shm` triplet out of the container, querying the copy — never host `sqlite3` on the +> live WAL file. + +## Headline + +**The gate did not get past its own first boot before finding real defects.** Four production bugs, +all fixed on-branch with regression tests, plus a fifth finding in the test suite itself. Three of +the four crash-looped every driver node; the fourth silently stopped alarm history everywhere. + +Checks 1, 2, 5 and 6 pass. **Checks 3 and 4 are NOT satisfied** — see "The blocker" below; they +cannot be run as written on a rig that has no per-pair redundancy roles, and the defect they were +meant to confirm turned out to be the opposite of what the plan assumed. + +## Seeding the migration + +The rig had no pre-consolidation `alarm-historian.db`, so one was synthesised per node and `docker +cp`-ed to `/app/alarm-historian.db` (the WORKDIR-relative default) into created-but-not-started +containers — the exact upgrade shape. + +Deliberately overlapping content, to test D-6 in production: **6 rows on site-a-1, 5 on site-a-2, +2 of them byte-identical payloads** (the warm-pair duplication `HistorianAdapterActor` produces in +every boot window). 11 legacy rows, **9 distinct**. + +## Defects found and fixed + +### 1. Empty `ServerHistorian:ApiKey` crash-loops the host (was classified "degrades") + +`AlarmHistorian:Enabled=true` requires the gateway alarm writer, which requires a key. +`HistorianGatewayClientOptions.Validate()` throws `The gateway API key must not be empty` inside the +client factory during Akka startup, so every driver node crash-looped. + +`ServerHistorianOptionsValidator` exists precisely to convert that class of failure into a named +`OptionsValidationException` — but its documented fail tier explicitly excluded `ApiKey`, reasoning +that a keyless client "degrades rather than crashes (the gateway rejects calls)". **That premise is +false**: the client validates its own options at construction, so the process dies before any call. +Fixed; the key is never echoed in the message. + +### 2. `UseTls` / endpoint-scheme mismatch crash-loops the host + +Immediately after fixing #1, the rig crash-looped again: `UseTls` defaults to `true`, the rig +endpoint is `http://`, and the client throws `UseTls requires an https gateway endpoint`. The +inverse (`https://` + `UseTls=false`) throws too — both strings confirmed in the shipped client +assembly. Setting an `http` endpoint without clearing `UseTls` is an ordinary migration slip that +takes the host down. Now validated in both directions. + +### 3. Plaintext h2c was *unreachable* — every `http://` deployment crashed + +Third crash-loop, and a genuine product bug rather than operator config. +`HistorianGatewayClientAdapter.Create` forwarded the TLS-only options unconditionally, and +`AllowUntrustedServerCertificate` defaults to `false`, so it always sent +`RequireCertificateValidation=true` — which the client rejects outright when `UseTls=false` +(`RequireCertificateValidation is a TLS-only option and requires UseTls=true`). + +CLAUDE.md and `docs/Historian.md` both document `http://` as the supported way to select h2c, but +**no h2c configuration could start**, and the only workaround was to claim +`AllowUntrustedServerCertificate=true` — a certificate posture for a connection with no certificate. +Fixed: TLS-only options stay at their defaults when `UseTls=false`. + +### 4. THE BLOCKER — the drain gate deferred to a Primary that cannot deliver + +With the rig finally up, **every driver node logged `Historian drain suspended`. Nothing drained +anywhere** — including the two site-b nodes, which have no redundancy peer and no replication at all. +Before Phase 2 this deployment drained fine, because the drain was ungated. + +Root cause, established from the rig rather than guessed: + +- `RedundancyStateActor` derives roles from Akka's **cluster-wide** `RoleLeader("driver")`. +- `central-1`/`central-2` carry **both** `admin` and `driver` Akka roles (`OTOPCUA_ROLES=admin,driver`). +- So the elected driver Primary is **central-1** — a node that replicates nobody's LocalDb and does + not even run the alarm historian. +- Every site node was `Secondary`, saw that a Primary existed, and stood down in its favour. + +**The redundancy role is a cluster-wide election; the alarm queue is pair-local.** Deferring on +"somebody is Primary" is therefore wrong in principle: in a multi-pair fleet the Primary is usually +in another pair and holds none of this node's rows. The consequence is not a duplicate — it is the +buffer growing on every node until the capacity wall evicts the oldest events, i.e. silent permanent +loss of the audit trail the queue exists to protect. + +Fixed in three layers, each with a positive control: + +1. `PrimaryGatePolicy.ShouldDrainAlarmHistory(role, queueSharingPeerIsPrimary)` — a **separate** + policy from `ShouldServiceAsPrimary`. Unknown role ⇒ drain. A node stands down only for a Primary + that holds its rows. The two gates now deliberately disagree, and a test pins that disagreement so + nobody "harmonises" them back. +2. `DriverHostActor` publishes that verdict, matching the snapshot's Primary against this node's + configured LocalDb replication peer host. +3. `AddAlarmHistorian` short-circuits the gate entirely when replication is not configured — a node + whose rows exist nowhere else must never defer. The predicate tests **both** `Replication:PeerAddress` + and `SyncListenPort`, because only the dialing half sets the former and both halves share the queue. + +The asymmetry of the error costs is what drives every one of these: a false allow costs a duplicate +history row, which the design already accepts (*at-least-once across a failover, by design*, and +payload-hash ids collapse identical events); a false deny loses data silently. + +### 5. A third vacuous test — `AwaitAssert` on the seeded value + +Deleting the guard (publishing the old write-gate verdict) left `DriverHostActorRoleViewTests` +**green**. `AwaitAssert` polls until an assertion passes, and the assertion was "the view reads +open" — which is also the value the view is *seeded* with, so it succeeded at the first poll, before +the actor had processed anything. It could not distinguish a correct publish from no publish at all. + +Fixed by asserting on the **sequence of published values** via a recording view, never on current +state. Re-run under the control: red, for exactly the cases that matter. This is the same +"absence-assertion passes by race" trap recorded for #485/#486, in a new disguise. + +## Checks + +| # | Check | Result | Evidence | +|---|---|---|---| +| 1 | Migration ran; `.migrated` sidecar on both site-a nodes; row counts match | ✅ PASS | `alarm-historian.db.migrated` present on both. 11 legacy rows across the two files → **exactly 9 rows on each node**, id sets byte-identical — the 2 shared payloads collapsed, proving D-6's payload-hash identity in production. Each node holds the *other's* rows (`legacy/a1-only-*` present on a-2 and vice versa). Legacy `attempt_count` distinction survived the copy (the 3-attempt row still leads the 0-attempt rows), `last_error` carried, 0 dead-lettered | +| 2 | Alarm burst converges: identical rowsets, oplog drains to 0, dead letters 0 | ✅ PASS | Identical id sets on both nodes; `__localdb_oplog` depth **0** on both; **0** dead-lettered. Drain visibly live against the deliberately unresolvable gateway — `attempt_count` climbing 33 → 42 → 168 with `last_error='retry-please'`, which is exactly the buffering behaviour the queue exists for | +| 3 | Only the primary drains | ❌ **NOT SATISFIED** — see defect 4 | As written the check assumes a per-pair Primary exists. On this rig the elected Primary is a central node outside both pairs, so the honest post-fix behaviour is that **all four nodes drain**. No node can currently identify a queue-sharing Primary: site-b is unpaired, and of the site-a pair only the dialing half knows its peer. Safe (no loss, duplicates only), but the check's intent is unproven | +| 4 | Failover: standby resumes draining; count double-delivered events | ⛔ **NOT RUN** | Superseded by defect 4. A meaningful failover test needs a rig where the pair itself elects a Primary; measuring "double delivery" is meaningless while both halves drain unconditionally | +| 5 | Both nodes restarted together: clean rejoin, identical counts, no I/O errors | ✅ PASS | `docker compose restart site-a-1 site-a-2` → **0** `disk I/O error` / `SQLITE_IOERR` / `corrupt` lines and 0 fatals in either log; both back to 9 rows / 0 dead / oplog 0, still identical | +| 6 | site-b default-OFF pin: sink works locally, no sync traffic | ✅ PASS | `alarm_sf_events` created and registered on both site-b nodes; port **9001 not bound** (`/proc/net/tcp`), no sync listener, no replication config; both drain their own queue (defect 4's third fix) with 0 fatals | + +## The open design question + +The gate leaves one question that is a design fork, not a bug to patch: + +**A pair cannot currently identify its own Primary.** Redundancy roles are elected cluster-wide, but +the queue is pair-local. Only the dialing half of a pair knows its peer (`Replication:PeerAddress`); +the listening half sets `SyncListenPort` only. So the drain gate can never fully engage, and both +halves of a replicated pair drain — safe, but duplicated. + +### Follow-up analysis — the root cause is wider than the drain gate + +Two things assumed while writing the section above turned out to be wrong, and correcting them +changes the recommendation. + +**The rig is not misconfigured.** `central-1`/`central-2` carry `admin,driver` deliberately — the +compose says so in as many words ("central is admin,driver"), because they are themselves a +redundant pair that also runs the MAIN cluster's drivers. Stripping the driver role would change what +the rig models, not fix anything. + +**A "pair" is not a missing concept — it already exists, as the application Cluster.** OtOpcUa's +`Cluster`/`ClusterId` (the config-DB entity that owns drivers, devices and `ClusterNode` rows) is +exactly the unit a redundant pair belongs to. Phase 1 already relies on this: the deployment-artifact +cache is *keyed by ClusterId so a pair shares one entry*, and the rig's three application clusters +(MAIN, SITE-A, SITE-B) are precisely its three pairs. + +So the real defect is that **`RedundancyStateActor` elects one Primary per *Akka* cluster, while +every meaningful unit of redundancy is an *application* cluster.** The rig runs six driver nodes +across three application clusters in one Akka cluster — a topology the product otherwise supports +throughout — and exactly one of those six can ever be Primary. + +That mis-scoping is not confined to the alarm drain. Every consumer of the role inherits it: +`ServiceLevel`, the `alerts` emit gate, and the inbound device-write gate. On a multi-cluster fleet +today, only one driver node cluster-wide will service Primary-gated work at all. + +### Revised recommendation + +**(C), as a separate change with its own live gate — and explicitly *not* (A).** + +- (C) is no longer "invent a pair concept"; it is "elect per `ClusterId` instead of per Akka cluster", + using a first-class entity that already exists. It is feasible where it needs to be: + `RedundancyStateActor` has no DB access today, but it is an admin-role singleton in the same + assembly as `AdminOperationsActor`, which already reads and groups `ClusterNodes` by cluster. +- **(A) is now actively undesirable.** Deriving pair identity from `LocalDb:Replication:PeerAddress` + would introduce a *second*, parallel notion of "who is my partner" that (C) immediately obsoletes — + and it cannot be done cleanly anyway: the library exposes no initiator/passive flag, so making both + halves declare a peer means both halves *dial*, opening two duplex sessions purely to obtain a + configuration value. +- (C) touches the inbound device-write gate, which is safety-critical (the archreview 03/S4 + dual-primary fix). It therefore belongs in its own change, with its own live gate, rather than + bolted onto Phase 2. + +Until then this branch is safe in every topology — no configuration loses data — so what remains is +reclaiming the de-duplication benefit, not an outstanding hazard. + +## Not merged + +Per the plan, nothing on this branch is merged. diff --git a/docs/plans/2026-07-20-localdb-phase2-recon.md b/docs/plans/2026-07-20-localdb-phase2-recon.md new file mode 100644 index 00000000..1d858f5a --- /dev/null +++ b/docs/plans/2026-07-20-localdb-phase2-recon.md @@ -0,0 +1,371 @@ +# LocalDb Phase 2 — Recon findings + +**Date:** 2026-07-21 · **Branch:** `feat/localdb-phase2` · **Base:** `master` `2e46d054` + +Answers Task 0 of `2026-07-20-localdb-adoption-phase2.md`. Every claim carries a `file:line` +citation against the base commit. Read the **Deviations** section last — two of them change the +shape of Tasks 2/3 from what the plan assumed. + +--- + +## 1. The sink's table schema + +**STOP condition does NOT fire** — confirmed by re-reading the executed DDL, as the plan's +pre-answer instructed. + +Executed DDL: `SqliteStoreAndForwardSink.cs:652-670` (`InitializeSchema`). It matches the class +doc-comment at `:17-26` exactly — no drift between documented and executed schema. + +```sql +CREATE TABLE IF NOT EXISTS Queue ( + RowId INTEGER PRIMARY KEY AUTOINCREMENT, + AlarmId TEXT NOT NULL, + EnqueuedUtc TEXT NOT NULL, + PayloadJson TEXT NOT NULL, + AttemptCount INTEGER NOT NULL DEFAULT 0, + LastAttemptUtc TEXT NULL, + LastError TEXT NULL, + DeadLettered INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId); +``` + +| Question | Answer | +|---|---| +| BLOB columns? | **None.** All 8 columns are TEXT/INTEGER. Payload is `PayloadJson TEXT NOT NULL`. No base64 conversion needed; the 171 KB chunk sizing is moot. | +| PK autoincrement? | **Yes** — `RowId INTEGER PRIMARY KEY AUTOINCREMENT`. The migrator must mint deterministic `mig-{node}-{legacyId}` ids and the new table needs a TEXT PK, as the plan specified. | +| Indices to carry | One: `IX_Queue_Drain (DeadLettered, RowId)` — the drain's covering index. `alarm_sf_events` wants the same shape over (status, insertion order). | +| Largest realistic payload | `PayloadJson` is a serialized `AlarmHistorianEvent` — 10 scalar fields (`AlarmHistorianEvent.cs`), no collections, no nesting. Low single-digit KB worst case; `Comment` is the only loosely-bounded field. | + +**Legacy column list** for the migrator's `pragma_table_info` intersection check: +`RowId, AlarmId, EnqueuedUtc, PayloadJson, AttemptCount, LastAttemptUtc, LastError, DeadLettered`. + +`RegisterReplicated` will accept this table: no BLOB, and the PK will be app-minted TEXT. + +## 2. The public seam + +`IAlarmHistorianSink` (`IAlarmHistorianSink.cs:23-34`) is **two members**: + +```csharp +Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken); +HistorianSinkStatus GetStatus(); +``` + +The concrete `SqliteStoreAndForwardSink` additionally exposes, and these are all consumed +somewhere, so the rewire must keep them: + +| Member | Cited | Consumer | +|---|---|---| +| `StartDrainLoop(TimeSpan)` | `:189` | `ServiceCollectionExtensions.cs:105` (DI factory) | +| `DrainOnceAsync(CancellationToken)` | `:320` | tests only — the deterministic drive | +| `RetryDeadLettered()` | `:511` | operator action (AdminUI) | +| `CurrentBackoff` | `:676` | tests | +| `DebugCapacityProbeCount` | `:99` | tests | +| `DefaultCapacity` / `DefaultMaxAttempts` consts | `:49`,`:53` | `AlarmHistorianOptions.cs:38,45` | +| `IDisposable` | `:679` | DI + tests; **disposes the injected writer too** | + +There is **no separate enqueue/dequeue/markDelivered/deadLetter interface** — the plan's item 2 +guessed at one. Dequeue, delivered-marking and dead-lettering are all *private* to the sink +(`ReadBatch :532`, `DeleteRow :564`, `DeadLetterRow :573`, `BumpAttempt :587`). That is good news: +the rewire is entirely internal to one class and the public seam is untouched. + +## 3. Semantics to preserve + +All defaults live in **two** places that must stay in agreement: + +| Semantic | Sink default | Options default | Enforced at | +|---|---|---|---| +| `Capacity` 1,000,000 | `:49` | `AlarmHistorianOptions.cs:38` | `EnforceCapacityFastPathAsync :271` → `EnforceCapacityAsync :602` | +| `MaxAttempts` 10 | `:53` | `:45` | drain loop `:429` | +| Dead-letter retention 30 d | `:50` | `:41` | `PurgeAgedDeadLetters :638`, run once per drain tick | +| `BatchSize` 100 | ctor arg `:116` | `:31` | `ReadBatch :542` | +| `DrainIntervalSeconds` 5 | — | `:34` | `StartDrainLoop` via `ServiceCollectionExtensions.cs:105` | + +Behaviours that are **not** in the plan's list but are load-bearing, and which the cutover must +not silently drop: + +- **Backoff ladder** 1s→2s→5s→15s→60s (`:57-64`), applied to the timer's *next due time* + (`RescheduleDrain :224`) so an outage genuinely slows the cadence. +- **Overflow evicts oldest non-dead-lettered rows** with a WARN and a lifetime + `EvictedCount` counter (`:630-635`) — the durability guarantee is explicitly bounded. +- **Corrupt-payload rows dead-letter immediately** for their own RowId so they cannot stall the + queue head (`:344-361`). +- **Cardinality guard**: a writer returning ≠1 outcome per event is treated as a batch retry + (`:393-404`). +- **In-memory row counter with periodic resync** every 10,000 enqueues (`:89-96`, `:271-290`) — + a perf optimisation that avoids `COUNT(*)` on the hot enqueue path. +- **Post-dispose throwing contract**: `GetStatus`/`RetryDeadLettered`/`EnqueueAsync`/`StartDrainLoop` + throw `ObjectDisposedException`; `DrainOnceAsync` returns quietly (`:322`). Pinned by a + regression test (`SqliteStoreAndForwardSinkTests.cs:854-865`). + +## 4. Drain-worker lifecycle — **the finding that reshapes Task 3** + +> The plan asked "hosted service or actor?". **Neither.** + +The drain worker is a **self-rescheduling one-shot `System.Threading.Timer` owned by the sink +itself** (`_drainTimer`, `:76`; started by `StartDrainLoop :189`; callback `DrainTimerCallback :199`; +re-armed by `RescheduleDrain :224`). It is started from inside the DI singleton factory at +`ServiceCollectionExtensions.cs:105`, so it begins ticking the moment the first consumer resolves +`IAlarmHistorianSink`. + +Consequences for the Primary gate: + +1. **The gate has to live inside `DrainOnceAsync`**, not in a wrapper. There is no external + scheduler to gate. +2. **`Core.AlarmHistorian` cannot see `PrimaryGatePolicy`.** The policy is in + `Runtime/Drivers/PrimaryGatePolicy.cs`, and Runtime → Core is the reference direction. + `Core.AlarmHistorian` references only `Core.Abstractions` (`.csproj`). So the sink takes a + **`Func` gate delegate** (default: always-drain) and Runtime supplies the lambda that + calls `PrimaryGatePolicy`. No new project reference, no new interface in Core. +3. **A role view is still needed** because the timer fires outside any actor. See the next point + for who should own it. + +### There is already a Primary gate — on the *enqueue* side, with a different policy + +`HistorianAdapterActor` (`Historian/HistorianAdapterActor.cs`) already subscribes to +`RedundancyStateChanged` (`:86`, handler `:145`), caches `_localRole` (`:42`), and gates the +enqueue on `ShouldHistorize()` (`:97`): + +```csharp +private bool ShouldHistorize() => _localRole is not (RedundancyRole.Secondary or RedundancyRole.Detached); +``` + +This is **not** `PrimaryGatePolicy` — it ignores driver-member count and defaults to *write* while +the role is unknown. Its comment (`:68-71`) explains why it exists: DPS fans the Primary's single +`alerts` publish to **every** node's adapter, so without the gate both nodes of a pair would +double-write. + +**This is why today's ungated drain is safe, and why Phase 2 breaks that.** Today the Secondary +never enqueues, so its queue is empty and an ungated drain has nothing to send. Once +`alarm_sf_events` replicates, the Primary's rows land in the Secondary's table — and an ungated +drain there would double-deliver *every* event, continuously, not just at failover. **The drain +gate is not a nicety; it is required by the same commit that registers the table.** + +### Who owns the role view + +`HistorianAdapterActor`, not `DriverHostActor`. Both are spawned under +`WithOtOpcUaRuntimeActors` (`ServiceCollectionExtensions.cs:353`) which `Program.cs:133` calls only +under `hasDriver` — as are `AddAlarmHistorian` (`:175`) and `AddOtOpcUaLocalDb` (`:185`). So sink, +LocalDb and adapter are co-located by construction. The adapter wins over `DriverHostActor` +because it **already** holds the role for exactly this purpose; using it adds no new subscription +and no new way for the view to go stale while the sink keeps draining. + +`DriverMemberCount` is read off Akka cluster state in `DriverHostActor.DriverMemberCount() :1446` +(try/catch → 0 on a non-cluster provider ⇒ single-node posture). Task 3 will lift that into a +small shared helper rather than copy it. + +## 5. Other `DatabasePath` construction sites + +- `ServiceCollectionExtensions.cs:98` — the only production site. +- `appsettings.json:57` — `"DatabasePath": "alarm-historian.db"`. +- `AlarmHistorianOptions.Validate() :54-55` — warns when the path is relative. **This warning + disappears with the key**; the validator loses one of its five checks. +- `SqliteStoreAndForwardSinkTests.cs` — ~30 construction sites, all passing the per-test temp + `_dbPath` seeded at `:25` and deleted in `Dispose :32`. These are the tests Task 2 rewires onto + a temp-file `ILocalDb`. +- **`docker-dev/docker-compose.yml` has no `AlarmHistorian` keys at all** — the rig runs with + `Enabled=false` today. Task 6 is therefore an *addition*, not an edit. + +No tooling or CLI constructs the sink. + +## 6. How `Enabled=false` short-circuits + +`AddAlarmHistorian` returns before registering anything when the section is absent or +`Enabled != true` (`ServiceCollectionExtensions.cs:87`), leaving the `NullAlarmHistorianSink` +seeded by `AddOtOpcUaRuntime :49`. The Null sink is a pure no-op (`IAlarmHistorianSink.cs:37-52`). + +Confirming the plan's own answer: **the tables are created unconditionally in `OnReady`** (cheap, +empty) because `AddOtOpcUaLocalDb` is independent of the `AlarmHistorian` gate. Only the +sink/drain stay Null. This is also required for correctness — `RegisterReplicated` must run on +both pair members regardless of their historian config, or a node that later enables the sink +would write rows that are never captured (see the CDC note below). + +## 7. Library constraints re-confirmed (README, `ZB.MOM.WW.LocalDb` 0.1.3) + +- **BLOB columns are rejected at registration.** Not a problem here (§1). +- **CDC semantics** — rows that existed *before* `RegisterReplicated` are neither captured nor + snapshotted. This is the mechanical reason the migrator must run **after** registration, and + the reason `LocalDbSetup.OnReady`'s ordering comment is load-bearing (`LocalDbSetup.cs:23-31`). +- **LWW keyed on the PK** — two nodes minting the same PK for *different* rows silently overwrite + each other; two nodes minting the same PK for the *same* row converge. Decision D-1 below turns + that second property into a feature. +- `ILocalDb` surface in use: `ExecuteAsync` / `QueryAsync(sql, rowMapper, params, ct)` / + `BeginTransactionAsync` / `CreateConnection` / `RegisterReplicated` + (`Deployment/LocalDbDeploymentArtifactCache.cs:77,82,205`). + +--- + +## Deviations and decisions + +Recorded here rather than raised as questions, per the plan's follow-up convention. + +### D-1 — Enqueue ids are a deterministic content hash, not `Guid.NewGuid()` + +**The plan says** GUIDs minted at enqueue (Task 2 step 2). + +**The problem.** The enqueue gate (`ShouldHistorize`, default-**write** on unknown role) and the +drain gate the plan specifies (`PrimaryGatePolicy`, default-**deny** on unknown role when paired) +disagree during the boot window. On a two-node pair before the first redundancy snapshot arrives, +**both** adapters enqueue the same DPS-fanned transition. With random GUIDs those are two distinct +rows that replicate into each other's tables — the buffer *duplicates* instead of converging, and +whichever node becomes Primary later delivers both copies. + +**The decision.** Mint `id` as a hash over the serialized payload. Two nodes independently +enqueuing the same fanned event produce the same PK, so LWW converges them to one row for free. +Two genuinely distinct events cannot collide: `AlarmHistorianEvent` carries a full-precision +`TimestampUtc` plus alarm id, kind, message and user, so an identical hash means an identical +event. + +This costs one `SHA256.HashData` per enqueue and removes a whole duplicate-row class. It also +slightly *improves* today's behaviour, where the same boot-window fan-out is already +double-delivered because the drain is ungated on both nodes. + +`ShouldHistorize` itself is left unchanged — out of scope, and with D-1 in place the two gates +disagreeing is no longer harmful. + +### D-2 — Gate denial must be observable + +`PrimaryGatePolicy` defaults to **deny** when the role is unknown on a multi-driver cluster. That +is the correct safety posture, but it means a redundancy-snapshot identity mismatch — the +documented 03/S5 shape that `DriverHostActor.cs:1485-1491` already warns about once — would +silently stop alarm history on *both* nodes, degrading only as retry/eviction growth minutes later. + +Task 3 will therefore: log the skip once per gate-state transition (not per tick — the drain runs +every 5 s), and surface it in `HistorianSinkStatus` so `/healthz` and the AdminUI can see +"buffering, not draining, because not Primary" rather than an unexplained rising queue depth. +Adding a `HistorianDrainState` member is additive; Task 3 will check every `switch` over it. + +### D-3 — Tasks 2 and 3 will land as one commit + +The plan anticipated this. Confirmed necessary: the gate delegate is a constructor parameter of +the rewritten sink, so the sink cannot compile in its final shape without it, and an intermediate +commit with a rewired-but-ungated sink would be a commit in which a replicated table is drained by +both nodes. Not a state worth having in history. + +### D-5 — The table keeps the legacy delete-on-ack shape, not a `status` column + +**The plan proposes** `status TEXT NOT NULL DEFAULT 'pending'` with values `pending | delivered | +dead`, and drops the legacy `LastError` column. + +**Two problems.** A `delivered` status means acknowledged rows accumulate in the table forever — +the plan specifies no sweeper for them, and they would also have to be excluded from the capacity +count, changing a semantic §3 says to preserve. And `LastError` is not dead weight: `DeadLetterRow` +writes the failure reason into it and `RetryDeadLettered` clears it, so it is the only operator- +facing record of *why* a row was dead-lettered. + +**The decision.** Keep the legacy shape: DELETE on acknowledgement, `dead_lettered` as the same 0/1 +flag, and retain `last_error`. Deleting keeps the table bounded with no second sweeper, and the +replication engine carries the delete as a tombstone (pruned after `TombstoneRetention`, 7 d), so +the peer drops its copy too — which is the property the plan wanted the `delivered` status for. +Columns then map 1:1 onto the legacy table, making the Task-4 migrator a straight copy. + +One genuine change: the drain's `ORDER BY` moves from `RowId ASC` to `enqueued_at_utc, id`, because +a hashed TEXT id carries no insertion order. Round-trip ("O") timestamps sort lexicographically in +chronological order, and `id` makes the ordering total; the index covers both. + +### D-6 — The migrator keys on the payload hash, not `mig-{node}-{legacyId}` + +**The plan says** deterministic ids of the form `mig-{NodeName}-{legacyId}`, mirroring ScadaBridge. + +**Why that is not enough here.** Node-prefixing does solve the collision the legacy AUTOINCREMENT +key would otherwise cause — node A's `RowId 7` and node B's `RowId 7` are different alarms — but it +*preserves* a duplication that ought to be collapsed. A warm pair's two legacy files **overlap**: +`HistorianAdapterActor` default-writes while its redundancy role is unknown, so both nodes accepted +the same fanned transitions during every boot window they ever had. Prefixed ids would carry every +one of those duplicates into the merged buffer permanently, for the eventual Primary to deliver +twice. + +**The decision.** Reuse D-1's `AlarmSfSchema.DeriveId` (lifted out of the sink so both call sites +share one identity rule). Distinct events cannot collide; identical ones converge. It also removes +the migrator's only need for node identity from configuration, and makes a crash between commit and +rename harmless under `INSERT OR IGNORE` — which the plan's own idempotency requirement wanted +anyway. + +### D-7 — The migrator's tests live in `Host.IntegrationTests` + +The plan names `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests`. **That project does not exist.** The +tests sit beside `LocalDbSetupTests` in `Host.IntegrationTests`, which is where every other +LocalDb-facing test already lives. They are offline — a temp file and no external services. + +### D-4 — The live gate needs `MaxAttempts` raised on the rig + +Not actionable until Task 8, recorded now so it is not rediscovered there. The rig has no +HistorianGateway, so every drain attempt fails. With `MaxAttempts: 10` and the backoff ladder +capping at 60 s, buffered rows dead-letter roughly five minutes into the outage — which would make +the gate's "dead letters 0" check (step 2) fail for reasons that have nothing to do with +replication. Task 6 should set a high `MaxAttempts` on the rig, or Task 8 should assert on +dead-letter *timing* instead. + +Related: `Program.cs:170-173` shows an `AlarmHistorian:Enabled=true` / +`ServerHistorian:Enabled=false` deployment is explicitly supported and warned about — which is +exactly the rig's shape. Task 6 must confirm `ServerHistorianOptionsValidator` does not fail host +start for a disabled section with an empty `Endpoint`. + +--- + +## Guard-deletion evidence (recorded at the Task 7 DoD sweep) + +Every guard this phase adds was proved by deleting it and watching the tests go red. Two of those +runs found tests that would have passed over a broken implementation. Both are recorded here because +the *shape* of the vacuity is reusable, not just the fix. + +### Control 1 — remove `IRedundancyRoleView.Publish` from `DriverHostActor` + +**3 of 6** `DriverHostActorRoleViewTests` went red. The other three assert +`ShouldServiceAsPrimary == true`, which is exactly the value the view is **seeded** with — so they +cannot distinguish "published true" from "never published at all". That split is expected and +acceptable *given* the three that do go red pin the false cases, which only a real publish can +produce. Worth knowing before someone reads a future 3/6 result as a regression. + +### Control 2 — remove `RegisterReplicated("alarm_sf_events")` from `LocalDbSetup.OnReady` + +**3 of 4** `AlarmSfConvergenceTests` went red immediately. The fourth, +`TheSameEventOnBothNodes_ConvergesToOneRow`, **passed vacuously**: with replication switched off +each node trivially held its own single row, and "count == 1 on both nodes" was satisfied by two +completely unconverged databases. The assertion could not tell convergence from isolation. + +**Fix:** enqueue a second, *distinct* event on node B and require **both** nodes to hold two rows. +Isolation now yields 1 and 2; only convergence yields 2 and 2. Re-run under the control: red. +Control restored: all 4 green. + +The general trap — an assertion whose expected value is also what a *disabled* system produces — +is the same one recorded in the `#485`/`#486` unreadable-artifact defect class. + +### Exact-set replicated-table pins + +Two, not one, and both assert set equality (ordered `ShouldBe` against the literal three-table list, +so an added *or* dropped registration fails): + +- `LocalDbSetupTests` — drives the production `OnReady` callback directly. +- `LocalDbWiringTests` — resolves `ILocalDb` from the full driver DI graph, which is what catches a + registration that never reaches a running host. + +## Test-suite evidence (Task 7 DoD sweep) + +Full solution run on the branch, compared against a **full solution run on a detached worktree at +the pre-branch baseline `2e46d054`**. Comparing failure *sets*, not counts, because the suite has a +standing set of environment- and load-dependent failures that a raw count would hide. + +**The two failure sets are identical — all 13 tests, same names, both runs. Zero new failures.** + +| Assembly | Baseline | Branch | Delta | +|---|---|---|---| +| `Core.AlarmHistorian.Tests` | 0 failed / 29 passed | 0 failed / **32** passed | **+3** (the drain-gate tests; 29 ported from the old sink's suite) | +| `Runtime.Tests` | 1 failed / 418 passed | 1 failed / **424** passed | **+6** (`DriverHostActorRoleViewTests`) | +| `Host.IntegrationTests` | 2 failed / 169 passed | 2 failed / **186** passed | **+17** (migrator + convergence + pin updates) | + +The 13 standing failures, and why each is not this branch's: + +- **3 × `DriverTypeNamesGuardTests`** — `ArgumentNullException (secretResolver)` out of + `GalaxyDriverFactoryExtensions.Register`, reached by reflection. A real pre-existing defect in a + guard test, reproduced exactly on the baseline worktree. Unrelated to this phase. +- **4 × `AbLegacy.IntegrationTests`**, **3 × `OpcUaClient.IntegrationTests`**, + **1 × `DriverProbeHandshakeE2eTests.AbCip_Green_AgainstSim`** — driver fixtures on the + `10.100.0.35` docker host are not running. +- **1 × `RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed`** + — times out under full-suite load; passes in isolation. +- **1 × `ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks`** — fails + under full-suite load ("writer must have been called at least twice"), and `Runtime.Tests` passes + **425/425** when run alone. Also fails on the baseline. Worth noting for anyone reading a future + run: this one is *newly observed* here only because the previous sweep's log was truncated, not + because the branch introduced it. diff --git a/docs/plans/2026-07-21-per-cluster-mesh-design.md b/docs/plans/2026-07-21-per-cluster-mesh-design.md new file mode 100644 index 00000000..addeb661 --- /dev/null +++ b/docs/plans/2026-07-21-per-cluster-mesh-design.md @@ -0,0 +1,276 @@ +# Per-Cluster Akka Mesh — design + +> **Status:** design for review. No implementation planned yet. +> **Decisions settled 2026-07-21** — see §6: one central DB with driver nodes disconnected from it, +> the two-node keep-oldest gap accepted as ScadaBridge accepts it, and their auth posture matched for +> now. + +## 1. What this changes + +Today OtOpcUa runs **one Akka mesh containing every node of every application Cluster**. That was a +deliberate choice — `docs/plans/2026-06-07-per-cluster-scoping.md`, "Per-ClusterId Scoping +(hub-and-spoke **single mesh**)" — so the deploy channel could stay in-mesh: AdminUI → +`admin-operations` singleton → `deployments` topic → every node filters to its own `ClusterId`. + +The proposal is to move to the sister project's shape: **one Akka mesh per application Cluster, +two nodes maximum**, with central and clusters joined by explicit transports instead of cluster +gossip. + +The motivation is that redundancy becomes correct *by construction*. Every Primary-gated decision +(inbound device writes, native alarm acks, fleet alerts, ServiceLevel, and — since Phase 2 — the +alarm-history drain) currently asks "am I the driver Primary?" of a **cluster-wide** election, while +every resource being gated is **pair-local**. Splitting the mesh collapses the two scopes into one +and the question stops being ambiguous. + +## 2. What ScadaBridge actually does + +Researched directly from `/Users/dohertj2/Desktop/ScadaBridge`. Five things differ from the +summary in its own CLAUDE.md, and they matter: + +**Three transports cross the boundary, not two.** + +| Transport | Direction | Carries | +|---|---|---| +| Akka **ClusterClient** | central → site (+ replies) | command/control: deploy notify, lifecycle, queries, subscribe handshakes | +| **gRPC** server-streaming | **central dials into the site** | real-time attribute + alarm events | +| Plain **HTTP**, token-gated | site → central | the deployment config itself (notify-and-fetch) | + +**The gRPC direction is inverted from the obvious guess.** Data flows site→central, but *each site +node hosts the gRPC server* (Kestrel h2c :8083) and *central is the client*. There is no gRPC server +on central at all. + +**Active node = OLDEST Up member, explicitly not the cluster leader.** +`ActiveNodeEvaluator.SelfIsOldestUp` is documented as *"THE single definition of 'active node'"*: + +> *"Cluster LEADERSHIP (lowest address) is an Akka-internal concept that diverges from singleton +> placement permanently once the original first node restarts and rejoins; every product-level +> active/standby decision must use this evaluator, never `cluster.State.Leader`."* + +The equivalence **oldest-Up == where `ClusterSingletonManager` places singletons** *is* the design. + +**All clusters share one ActorSystem name** (`"scadabridge"`, hardcoded). They are separate clusters +only by seed-node partitioning — required, because Akka.Remote address matching means ClusterClient +could not reach a differently-named system. + +**Site nodes carry two roles:** `"Site"` and `"site-{SiteId}"`. Singletons scope to the +**site-specific** role. + +Other load-bearing details: + +- **Central discovers sites from the database** (a `Site` entity with `NodeAAddress`/`NodeBAddress` + + `GrpcNodeAAddress`/`GrpcNodeBAddress`), refreshed every 60 s and on admin change. Sites discover + central from **appsettings** — static, restart required. The asymmetry is deliberate. +- **Exactly one actor is exposed per side** via `ClusterClientReceptionist.RegisterService` — + `/user/central-communication` and `/user/site-communication` — registered **per node, not as a + singleton**, so contact rotation reaches whichever node answers. +- **No central buffering when a site is unreachable.** The send is dropped with a warning and the + caller's Ask times out. *"It keeps the central coordinator stateless with respect to site + availability."* A `ConnectionStateChanged` mechanism was built for this and **deleted as dead + code**. +- **Every unhandled message replies with a typed failure rather than dropping**, so a central Ask + can never stall on a missing handler. +- **Sender preservation is the whole Ask idiom**: `_client.Tell(new ClusterClient.Send(path, msg), + Sender)` routes the reply straight back to the waiting Ask, bypassing the comm actor. + +### The registered outage gap — read before copying + +> `Component-ClusterInfrastructure.md:113`: *"Only the FIRST seed listed in `Cluster:SeedNodes` may +> self-join to form a *new* cluster… a lone restarted non-first-seed node (with the first seed still +> down) loops on `InitJoin` forever — never `Up`, never routable. This is why the two-node +> keep-oldest **oldest/active-node crash is a total-outage gap**: after the oldest dies the younger +> survivor self-downs, and it cannot re-bootstrap alone. Recovery is operator-driven."* + +Their failover drill has a mode that exists *"to make the registered gap observable — not to pretend +it is covered."* **A two-node keep-oldest mesh has an acknowledged total-outage hole.** If OtOpcUa +adopts 2-node meshes, this must be decided deliberately, not inherited. + +### Both inter-cluster transports are unauthenticated + +Akka remoting has no TLS, no secure cookie, no `trusted-selection-paths`. The gRPC listener is h2c +with no auth covering `SiteStreamService` — including two Pull RPCs that return audit rows. The only +authenticated pieces are the HTTP fetch token and `LocalDbSyncAuthInterceptor`, which gates *only* +`/localdb_sync.v1.LocalDbSync/`. Their boundary assumes a trusted network. + +**OtOpcUa already ships that same fail-closed interceptor** (LocalDb Phase 1) — it is a ready-made +template if we choose to authenticate more than they did. + +## 3. What OtOpcUa has today + +**Nine cross-node DPS topics plus a singleton**, all currently relying on one mesh: + +| Channel | Direction | Purpose | +|---|---|---| +| `deployments` / `deployment-acks` | central ↔ nodes | deploy dispatch + acks | +| `driver-control` | central → nodes | AdminUI Reconnect/Restart | +| `redundancy-state` | central → nodes | roles + peer probe results | +| `alerts` | nodes → central | fleet alarms → `/alerts` | +| `driver-health`, `driver-resilience-status`, `fleet-status`, `script-logs` | nodes → central | AdminUI live panels | +| `admin-operations` singleton | central | operator ack/shelve into engines | + +Three facts make the split cheaper than that table suggests: + +1. **The deploy path is already notify-and-fetch.** `DispatchDeployment` carries only + `DeploymentId` + `RevisionHash` + `CorrelationId`; the node fetches the artifact itself. We + independently arrived at the pattern ScadaBridge had to retrofit, and we do **not** carry its + 128 KB Akka frame exposure on this path. +2. **Deploy state already has a DB substrate.** `ConfigPublishCoordinator` persists per-node ACKs to + `NodeDeploymentState` so a singleton failover recovers in-flight state from the DB. +3. **`ClusterNode` rows already enumerate every node per application Cluster** — the same table + `AdminOperationsActor` groups by cluster. This replaces the coordinator's one genuinely + mesh-bound dependency: it derives its expected-ack set from `Akka.Cluster.State.Members` filtered + by role. + +So the deploy channel needs only a small notify + ack transport. **The nine live-telemetry channels +are the real work**, and they are exactly what ScadaBridge built ClusterClient + gRPC for. + +## 4. A defect to fix regardless of this design + +OtOpcUa currently uses **two different node-selection rules at once**: + +- Split-brain resolution is **keep-oldest** (`Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf:49`). +- `ClusterSingletonManager` places singletons on the **oldest** member. +- But `RedundancyStateActor.BuildSnapshot` elects Primary from **`RoleLeader("driver")`** — lowest + address among driver members. + +Those select the same node only by coincidence. After a restart-and-rejoin the role leader and the +oldest member diverge permanently, which means the node the SBR protects and hosts every singleton +on need not be the node the data-plane gates consider Primary. This is precisely the rule ScadaBridge +forbids by name, and for the reason it gives: *"both sides claim leadership during a partition"* — +the dual-primary shape archreview 03/S4 exists to prevent. + +**Switching the role derivation to oldest-Up-with-role is small, is correct under either topology, +and should not wait for this design.** It also happens to be exactly what the separate-mesh model +needs, so it is not throwaway work. + +## 5. Target architecture + +Mirror ScadaBridge's shape, adapted to OtOpcUa's existing substrate: + +- **One Akka mesh per application `Cluster`, two nodes max.** Same ActorSystem name (`otopcua`) + everywhere; separation by seed-node partitioning only. +- **Roles per node:** `driver` plus a cluster-specific `cluster-{ClusterId}`, with singletons scoped + to the cluster-specific role. +- **Active node = oldest Up member with role** (§4), one definition, used by the data-plane gates, + ServiceLevel, and the alarm drain alike. +- **Central ↔ cluster command/control over ClusterClient**, with exactly one receptionist-registered + actor per side and central discovering cluster node addresses from `ClusterNode` rows (extended + with Akka + gRPC addresses, mirroring their `Site` entity). +- **Deploy stays notify-and-fetch, but the fetch changes source.** The notify crosses via + ClusterClient; the artifact then comes **from central**, not from the ConfigDb the driver node no + longer connects to (§6.1), and is cached in LocalDb. The coordinator's expected-ack set comes from + `ClusterNode` rows instead of cluster membership. +- **Driver nodes hold no ConfigDb connection.** LocalDb becomes their steady-state configuration + store; five current DB consumers are re-homed or re-sourced per §6.1. +- **Live telemetry over gRPC**, central dialling into each cluster node, replacing the seven + observability topics with one stream contract carrying a `oneof` event — additive-only field + evolution, contract-locked by test. + +**Copied:** their node-local configuration store fed by fetch-and-cache (§6.1), their oldest-Up +active-node rule (§4), their two-node keep-oldest posture including its acknowledged outage gap +(§6.2), and their unauthenticated inter-cluster transports (§6.3). + +**Deliberately not copied:** their *transient-only* central alarm cache. OtOpcUa persists alarm +history centrally through the historian, and Phase 2's replicated store-and-forward buffer already +guarantees delivery across a failover — so there is no reason to adopt a design whose stated +trade-off is that a new active node re-seeds alarm state from scratch. + +## 6. Decisions (settled 2026-07-21) + +### 6.1 DECIDED — one central DB, and driver nodes never connect to it + +**Verified in ScadaBridge before adopting.** `Program.cs` calls +`AddConfigurationDatabase(configDbConnectionString)` at line 264 — inside the Central branch +(89–478). The Site branch begins at line 479. **Site nodes never register the central configuration +database at all**; they receive config from central (notify → token-gated HTTP fetch) and persist it +locally. There is still exactly one authoritative configuration database — it is simply +central-only. + +OtOpcUa adopts the same shape: **the ConfigDb stays the single source of truth, and driver nodes stop +connecting to it.** They obtain their configuration from the central nodes and cache it locally in +LocalDb. + +**This promotes LocalDb from an outage cache to the driver node's steady-state configuration store.** +That is a reframing of the Phase 1 work rather than a contradiction of it: boot-from-cache stops +being the exceptional path and becomes the normal one, and "central SQL unreachable" stops being a +degraded mode for driver nodes because they never held a connection to lose. The chunking, +SHA-256 verification, newest-2 retention and pair replication all carry over unchanged. + +**Scope — five driver-side ConfigDb consumers must be re-homed or re-sourced.** Audited: + +| Consumer | Current use | Disposition | +|---|---|---| +| `DriverHostActor` | artifact fetch + ack writes (5 `CreateDbContext` sites) | artifact via fetch-and-cache; acks via the ack transport | +| `EfAlarmConditionStateStore` | Part 9 alarm condition state | **move to LocalDb** — it is pair-local state, the same journey Phase 2 made for the alarm S&F buffer | +| `DbHealthProbeActor` | DB reachability feeding ServiceLevel tiering | **semantics change** — a driver node has no DB to probe; decide what health input replaces it | +| `OpcUaPublishActor` | (audit required) | TBD | +| `ServiceCollectionExtensions` | registration | follows the above | + +`DbHealthProbeActor` deserves particular care: DB health is currently a ServiceLevel input, and +under this model it stops existing for driver nodes. That is a behaviour change on a client-visible +value, not just an internal refactor. + +### 6.2 DECIDED — accept the two-node keep-oldest gap, as ScadaBridge does + +**Status corrected.** What was resolved is the *documentation and the drill*, not the gap. Gitea +**PR #12** (closed) — — "R2-01: Cluster, +Host & Failover round-2 fixes" carried: + +- **T1** (`badc97af`) — the failover drill now kills the STANDBY by default, and its `active` mode + *"measures the registered keep-oldest outage instead of pretending recovery"*. +- **T2** (`d5364506`) — per-direction failover docs, dropping what the commit message calls *"the + undeliverable ~25s active-crash promise"*. + +So the closed work made the gap **honest**, not absent. `Component-ClusterInfrastructure.md:113` +still reads: *"Removing the gap itself is the **registered deferred keep-oldest topology/strategy +decision** (master tracker 2026-07-08, owner: user)."* There is no open Gitea issue for it — it is +tracked on that master tracker, not in the issue list. + +OtOpcUa inherits the same gap and, for now, the same posture. Worth recording so it is a known +accepted risk rather than a surprise: after the oldest node of a two-node mesh dies, the survivor +self-downs and cannot re-bootstrap alone, because only the first-listed seed may self-join. Recovery +is operator-driven — restart the dead first seed, or restart the survivor with a self-first seed +override. + +### 6.3 DECIDED — match ScadaBridge's auth posture for now + +Inter-cluster transports stay unauthenticated and unencrypted, as theirs are: the boundary assumes a +trusted network. Recorded as an accepted risk rather than an oversight, with two notes for whenever +it is revisited: `LocalDbSyncAuthInterceptor` is already in our tree as a fail-closed template, and +the surface most worth gating first is any Pull-style RPC that returns historical rows. + +## 7. Sequencing sketch + +Deliberately not a task plan — per-phase plans follow, one at a time. + +| Phase | Content | Independent of the split? | +|---|---|---| +| 0 | Oldest-Up role derivation (§4) | **Yes** — ship first | +| 1 | `ClusterNode` gains Akka + gRPC address columns; coordinator sources its expected-ack set from the DB | Yes | +| 2 | Comm actors + receptionist registration; ClusterClient transport; deploy notify + acks across the boundary | No | +| 3 | Config fetch-and-cache: artifact served by central, driver nodes read LocalDb (§6.1) | No | +| 4 | Cut the driver-side ConfigDb connection: re-home `EfAlarmConditionStateStore`, resolve the `DbHealthProbeActor` ServiceLevel input, audit `OpcUaPublishActor` | No | +| 5 | gRPC stream contract; migrate the seven observability topics | No | +| 6 | Mesh partition: per-cluster seed nodes, cluster-scoped roles + singletons; rig models the real topology | No | +| 7 | Failover drill (both directions, per §6.2); live gate | No | + +Phases 3 and 4 are the ones that change a running system's data path rather than its wiring, and each +deserves its own live gate. + +## 8. Risks + +- **LocalDb becomes load-bearing for configuration, not just resilient.** Phase 1 built it as a + fallback; §6.1 makes it the driver node's only config store. A cache defect that was previously a + degraded-mode bug becomes a total-availability bug — the #485 unreadable-artifact class, with the + blast radius raised. +- **`DbHealthProbeActor` feeds a client-visible value.** Removing the driver-side DB connection + changes what ServiceLevel means on those nodes; that is an interop-visible change, not internal. +- **The rig currently models the topology that would be abolished.** Phase 6 rewrites + `docker-dev/docker-compose.yml` substantially, and every live gate that depends on it. +- **Losing gossip loses free fleet-wide observability.** Seven AdminUI panels are fed by DPS today; + each needs an explicit stream and a reconnect story. +- **Akka frame size.** We are clean on the deploy path, but anything new that carries payload over + ClusterClient inherits the 128 KB default with `log-frame-size-exceeding` off — a silent + single-message drop that leaves the association healthy. If we send payload at all, set both. +- **Do not stack application-level last-write-wins on LocalDb's HLC.** Their `SiteStorageService` + carries this warning in code. Verified: our `deployment_pointer` upsert is unconditional and the + alarm sink is idempotent on a payload hash, so we are currently clean — keep it that way. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs new file mode 100644 index 00000000..7705dc02 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +/// +/// DDL for the alarm store-and-forward buffer: one row per alarm event awaiting delivery to +/// the historian gateway. +/// +/// +/// +/// Replaces the standalone alarm-historian.db the sink used to own outright. Living +/// in the consolidated LocalDb file is what lets the buffer replicate to the redundant pair +/// peer, so a node that dies with undelivered alarm history no longer takes it to the grave. +/// +/// +/// Deliberately depends on nothing but so it can be applied +/// to any connection — the host's LocalDbSetup.OnReady in production, and a bare +/// connection in a test — without dragging the DI graph along. Mirrors +/// DeploymentCacheSchema. +/// +/// +/// Why is TEXT and app-minted. The legacy queue keyed on +/// RowId INTEGER PRIMARY KEY AUTOINCREMENT. Convergence is last-writer-wins over the +/// primary key, so two nodes independently allocating rowid 7 for different alarms would +/// silently overwrite one another. The sink mints the id from a hash of the payload, which +/// additionally makes the same event converge to one row when both nodes of a pair enqueue +/// it — as they legitimately do in the window before the first redundancy snapshot arrives. +/// +/// +public static class AlarmSfSchema +{ + /// Table holding queued alarm events awaiting delivery. + public const string EventsTable = "alarm_sf_events"; + + /// The single primary-key column. + public const string IdColumn = "id"; + + /// + /// Derives a row's primary key from its serialized payload. + /// + /// + /// + /// Deterministic rather than a fresh GUID, so that the same event arriving twice + /// converges to one row under last-writer-wins instead of duplicating. That happens for + /// real in two places: HistorianAdapterActor default-writes while its redundancy + /// role is unknown, so both nodes of a pair accept the same fanned transition in the + /// window before the first snapshot; and both nodes independently migrate their own + /// legacy queue file, which for a warm pair holds overlapping history. + /// + /// + /// Two genuinely distinct events cannot collide. AlarmHistorianEvent carries a + /// full-precision timestamp alongside the alarm id, transition kind, message and user, + /// so an equal hash means an equal event. + /// + /// + /// The serialized AlarmHistorianEvent. + /// The row's primary key. + public static string DeriveId(string payloadJson) + { + ArgumentNullException.ThrowIfNull(payloadJson); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))); + } + + /// + /// Creates the buffer table if it does not already exist. Idempotent. + /// + /// + /// An already-open connection. ILocalDb.CreateConnection() hands out open, + /// pragma-configured connections — do not call Open() on one. + /// + public static void Apply(SqliteConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + using var cmd = connection.CreateCommand(); + + // Columns map 1:1 onto the legacy Queue table so the one-time migrator is a straight copy + // and the drain keeps its existing semantics: dead_lettered is the same 0/1 flag, and an + // acknowledged row is DELETEd rather than marked. Deleting keeps the table bounded without + // a second sweeper, and the replication engine carries the delete as a tombstone, so the + // peer drops its copy too. + // + // The drain index orders by enqueued_at_utc because a hashed TEXT id carries no insertion + // order the way the legacy AUTOINCREMENT RowId did. Timestamps are round-trip ("O") format, + // so lexicographic ordering is chronological; id breaks ties so the order is total and the + // index covers the drain's read. + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS alarm_sf_events ( + id TEXT NOT NULL PRIMARY KEY, + alarm_id TEXT NOT NULL, + enqueued_at_utc TEXT NOT NULL, + payload_json TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_utc TEXT NULL, + last_error TEXT NULL, + dead_lettered INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS ix_alarm_sf_events_drain + ON alarm_sf_events (dead_lettered, enqueued_at_utc, id); + """; + cmd.ExecuteNonQuery(); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs index 7efaf3c9..72287399 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs @@ -4,13 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; /// The historian sink contract — where qualifying alarm events land. Ingestion routes /// through the HistorianGateway alarm writer (the gateway's SendEvent gRPC path) /// behind the durable store-and-forward queue. Tests use an in-memory fake; production uses -/// . +/// . /// /// /// /// is fire-and-forget from the engine's perspective — /// the sink MUST NOT block the emitting thread. Production implementations -/// () persist to a local SQLite queue +/// () persist to the node's local SQLite queue /// first, then drain asynchronously to the actual historian. Per the Phase 7 plan, /// failed downstream writes replay with exponential backoff; /// operator actions are never blocked waiting on the historian. @@ -79,6 +79,18 @@ public enum HistorianDrainState Idle, Draining, BackingOff, + + /// + /// Ticking, but not draining: this node does not hold the Primary role, so it leaves the + /// (replicated) queue for the node that does. + /// + /// + /// Distinct from so a rising queue depth on a Secondary reads as the + /// designed behaviour rather than a stalled drain. It is also how a misconfigured pair + /// becomes diagnosable: if BOTH nodes report this, no one is draining and alarm history is + /// silently accumulating toward the capacity ceiling. + /// + NotPrimary, } /// Returned by the historian alarm writer per event — drain worker uses this to decide retry cadence. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs similarity index 57% rename from src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs rename to src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs index a76bd1ee..8c696694 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/LocalDbStoreAndForwardSink.cs @@ -1,53 +1,54 @@ using System.Text.Json; -using Microsoft.Data.Sqlite; using Serilog; +using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; /// -/// Durable SQLite queue on the node absorbs every qualifying alarm event, a drain -/// worker batches rows to the Wonderware historian sidecar via -/// on an exponential-backoff cadence, and -/// operator acks never block on the historian being reachable. +/// Durable queue in the node's consolidated LocalDb absorbs every qualifying alarm event, a +/// drain worker batches rows to the historian gateway via +/// on an exponential-backoff cadence, and operator acks never block on the historian being +/// reachable. /// /// /// -/// Queue schema: -/// -/// CREATE TABLE Queue ( -/// RowId INTEGER PRIMARY KEY AUTOINCREMENT, -/// AlarmId TEXT NOT NULL, -/// EnqueuedUtc TEXT NOT NULL, -/// PayloadJson TEXT NOT NULL, -/// AttemptCount INTEGER NOT NULL DEFAULT 0, -/// LastAttemptUtc TEXT NULL, -/// LastError TEXT NULL, -/// DeadLettered INTEGER NOT NULL DEFAULT 0 -/// ); -/// -/// Dead-lettered rows stay in place for the configured retention window (default -/// 30 days) so operators can inspect + manually -/// retry before the sweeper purges them. Regular queue capacity is bounded — -/// overflow evicts the oldest non-dead-lettered rows with a WARN log. The -/// durability guarantee is therefore bounded by : -/// under a sustained historian outage, accepted events may be evicted before -/// delivery. The counter makes -/// overflow visible to operators without requiring the WARN log to be scraped. +/// Rows live in alarm_sf_events (see ), which the host +/// registers for replication. That is the point of this type living on +/// rather than owning its own file: the buffer mirrors to the +/// redundant pair peer, so a node that dies holding undelivered alarm history no longer +/// takes it to the grave. +/// +/// +/// Only one node of a pair may drain. Replication puts the Primary's queued rows in +/// the Secondary's table too; an ungated drain there would re-deliver every event, +/// continuously. The drainGate constructor argument is how the caller scopes +/// draining to the Primary. Enqueue is gated separately and upstream, by +/// HistorianAdapterActor. +/// +/// +/// Dead-lettered rows stay in place for the configured retention window (default 30 days) +/// so operators can inspect + manually retry before the sweeper purges them. Regular queue +/// capacity is bounded — overflow evicts the oldest non-dead-lettered rows with a WARN log. +/// The durability guarantee is therefore bounded by : under a +/// sustained historian outage, accepted events may be evicted before delivery. The +/// counter makes overflow visible to +/// operators without requiring the WARN log to be scraped. /// /// /// Drain runs on a self-rescheduling one-shot . /// Exponential backoff on : -/// 1s → 2s → 5s → 15s → 60s cap — the backoff is applied to the timer's next -/// due-time, so a historian outage genuinely slows the drain cadence. -/// rows flip -/// the DeadLettered flag on the individual row; neighbors in the batch -/// still retry on their own cadence. +/// 1s → 2s → 5s → 15s → 60s cap — the backoff is applied to the timer's next due-time, so a +/// historian outage genuinely slows the drain cadence. +/// rows flip the dead_lettered flag +/// on the individual row; neighbors in the batch still retry on their own cadence. /// /// -public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable +public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposable { /// Default queue capacity — oldest non-dead-lettered rows evicted past this. public const long DefaultCapacity = 1_000_000; + + /// Default window dead-lettered rows are retained for before the sweeper purges them. public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30); /// Default max delivery attempts before a perpetually-retrying (poison) row is dead-lettered. @@ -62,7 +63,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable TimeSpan.FromSeconds(60), ]; - private readonly string _connectionString; + private readonly ILocalDb _db; private readonly IAlarmHistorianWriter _writer; private readonly ILogger _logger; private readonly int _batchSize; @@ -70,8 +71,9 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable private readonly TimeSpan _deadLetterRetention; private readonly int _maxAttempts; private readonly Func _clock; + private readonly Func _drainGate; - private readonly SemaphoreSlim _drainGate = new(1, 1); + private readonly SemaphoreSlim _drainGateLock = new(1, 1); private Timer? _drainTimer; private TimeSpan _tickInterval; private volatile int _backoffIndex; @@ -85,13 +87,16 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable private string? _lastError; private HistorianDrainState _drainState = HistorianDrainState.Idle; private long _evictedCount; + // Tracks the last gate decision so a denial is logged on the transition rather than on every + // tick. Null until the first drain runs. + private bool? _lastGateDecision; private long _queuedRowCount; // Probe counter — incremented every time we actually issue a real COUNT(*) for // capacity enforcement. Public for test instrumentation only. private long _capacityProbeCount; // After every Nth enqueue we resync the in-memory counter from storage to defend - // against silent drift (e.g. an external process editing the DB). + // against silent drift (e.g. the peer replicating rows in underneath us). private const long ResyncEnqueueInterval = 10_000; private long _enqueuesSinceResync; @@ -99,9 +104,13 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable public long DebugCapacityProbeCount => Interlocked.Read(ref _capacityProbeCount); /// - /// Initializes a new instance of the class with the specified configuration. + /// Initializes a new instance of the class. /// - /// The filesystem path to the SQLite database file. + /// + /// The node's local database. Its alarm_sf_events table must already exist and be + /// registered for replication — the host does both in LocalDbSetup.OnReady, before + /// any consumer can resolve this sink. + /// /// The alarm historian writer to handle batch forwarding. /// The logger for diagnostic output. /// The maximum number of rows to forward in a single batch. Defaults to 100. @@ -109,18 +118,24 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable /// The timespan to retain dead-lettered rows before purging. Defaults to 30 days. /// The maximum number of delivery attempts before a perpetually-retrying (poison) row is dead-lettered. Defaults to 10. /// Optional clock function for testing; defaults to . - public SqliteStoreAndForwardSink( - string databasePath, + /// + /// Consulted at the top of every drain tick; false skips the tick entirely, leaving + /// the queue intact. Callers running a redundant pair MUST supply a gate that is true on at + /// most one node, because the queue replicates — see the class remarks. The default + /// (null ⇒ always drain) is the correct single-node and test posture. + /// + public LocalDbStoreAndForwardSink( + ILocalDb db, IAlarmHistorianWriter writer, ILogger logger, int batchSize = 100, long capacity = DefaultCapacity, TimeSpan? deadLetterRetention = null, int maxAttempts = DefaultMaxAttempts, - Func? clock = null) + Func? clock = null, + Func? drainGate = null) { - if (string.IsNullOrWhiteSpace(databasePath)) - throw new ArgumentException("Database path required.", nameof(databasePath)); + _db = db ?? throw new ArgumentNullException(nameof(db)); _writer = writer ?? throw new ArgumentNullException(nameof(writer)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _batchSize = batchSize > 0 ? batchSize : throw new ArgumentOutOfRangeException(nameof(batchSize)); @@ -128,50 +143,11 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable _deadLetterRetention = deadLetterRetention ?? DefaultDeadLetterRetention; _maxAttempts = maxAttempts > 0 ? maxAttempts : throw new ArgumentOutOfRangeException(nameof(maxAttempts)); _clock = clock ?? (() => DateTime.UtcNow); - // DefaultTimeout gives ADO.NET command-level retry; the PRAGMA busy_timeout - // applied in OpenConnection backs it with SQLite's own busy-handler so an - // enqueue/drain collision waits out the file lock instead of throwing - // SQLITE_BUSY immediately. - _connectionString = new SqliteConnectionStringBuilder - { - DataSource = databasePath, - DefaultTimeout = 5, - }.ToString(); + _drainGate = drainGate ?? (static () => true); - InitializeSchema(); _queuedRowCount = ProbeQueuedRowCount(); } - /// - /// Open a connection with the busy timeout + WAL journal applied. SQLite - /// serializes writers with a file lock; the busy_timeout lets a writer wait - /// out a competing lock (default is 0 — fail fast), and WAL lets readers and - /// the single writer proceed without blocking each other. - /// - private SqliteConnection OpenConnection() - { - var conn = new SqliteConnection(_connectionString); - conn.Open(); - ApplyPragmas(conn); - return conn; - } - - /// Apply busy_timeout + WAL pragmas to an already-open connection (sync). - private static void ApplyPragmas(SqliteConnection conn) - { - using var pragma = conn.CreateCommand(); - pragma.CommandText = "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;"; - pragma.ExecuteNonQuery(); - } - - /// Apply busy_timeout + WAL pragmas to an already-open connection (async). - private static async Task ApplyPragmasAsync(SqliteConnection conn, CancellationToken ct) - { - using var pragma = conn.CreateCommand(); - pragma.CommandText = "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;"; - await pragma.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } - /// /// Start the background drain worker. Not started automatically so tests can /// drive deterministically. @@ -188,7 +164,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable /// The base interval between drain attempts. public void StartDrainLoop(TimeSpan tickInterval) { - if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink)); + if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink)); _tickInterval = tickInterval; _drainTimer?.Dispose(); // One-shot: dueTime = tickInterval, period = Infinite. RescheduleDrain re-arms @@ -239,25 +215,31 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) { if (evt is null) throw new ArgumentNullException(nameof(evt)); - if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink)); + if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink)); - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(cancellationToken).ConfigureAwait(false); - await ApplyPragmasAsync(conn, cancellationToken).ConfigureAwait(false); + await EnforceCapacityFastPathAsync(cancellationToken).ConfigureAwait(false); - await EnforceCapacityFastPathAsync(conn, cancellationToken).ConfigureAwait(false); + var payload = JsonSerializer.Serialize(evt); - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount) - VALUES ($alarmId, $enqueued, $payload, 0); - """; - cmd.Parameters.AddWithValue("$alarmId", evt.AlarmId); - cmd.Parameters.AddWithValue("$enqueued", _clock().ToString("O")); - cmd.Parameters.AddWithValue("$payload", JsonSerializer.Serialize(evt)); - await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + // ON CONFLICT DO NOTHING: re-enqueuing an identical event (the pair's boot-window + // double-accept) must not resurrect a row the drain already bumped or dead-lettered. + var inserted = await _db.ExecuteAsync( + """ + INSERT INTO alarm_sf_events + (id, alarm_id, enqueued_at_utc, payload_json, attempt_count) + VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0) + ON CONFLICT(id) DO NOTHING + """, + new + { + Id = AlarmSfSchema.DeriveId(payload), + AlarmId = evt.AlarmId, + EnqueuedAtUtc = _clock().ToString("O"), + PayloadJson = payload, + }, + cancellationToken).ConfigureAwait(false); - Interlocked.Increment(ref _queuedRowCount); + if (inserted > 0) Interlocked.Increment(ref _queuedRowCount); } /// @@ -268,15 +250,17 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable /// through which still runs a precise /// COUNT to compute the exact number of rows to evict. /// - private async Task EnforceCapacityFastPathAsync(SqliteConnection conn, CancellationToken ct) + private async Task EnforceCapacityFastPathAsync(CancellationToken ct) { var enqueuesSinceResync = Interlocked.Increment(ref _enqueuesSinceResync); var cached = Interlocked.Read(ref _queuedRowCount); - // Periodic resync — bounded amount of drift even under exotic conditions. + // Periodic resync — bounded amount of drift even under exotic conditions. Now doubly + // warranted: replication applies the peer's rows straight into the table, so the counter + // has a second way to fall behind that no local code path can observe. if (enqueuesSinceResync >= ResyncEnqueueInterval) { - await ResyncQueuedRowCountAsync(conn, ct).ConfigureAwait(false); + await ResyncQueuedRowCountAsync(ct).ConfigureAwait(false); cached = Interlocked.Read(ref _queuedRowCount); Interlocked.Exchange(ref _enqueuesSinceResync, 0); } @@ -286,55 +270,70 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable // Cached counter says we're at or above the capacity wall — fall back to the // precise path which probes COUNT(*) and evicts whatever's needed. - await EnforceCapacityAsync(conn, ct).ConfigureAwait(false); + await EnforceCapacityAsync(ct).ConfigureAwait(false); } /// Synchronously query COUNT(*) of non-dead-lettered rows. Used at startup. private long ProbeQueuedRowCount() { Interlocked.Increment(ref _capacityProbeCount); - using var conn = OpenConnection(); + using var conn = _db.CreateConnection(); using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0"; + cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0"; return (long)(cmd.ExecuteScalar() ?? 0L); } /// Re-sync the in-memory counter from storage (async path). - private async Task ResyncQueuedRowCountAsync(SqliteConnection conn, CancellationToken ct) + private async Task ResyncQueuedRowCountAsync(CancellationToken ct) { Interlocked.Increment(ref _capacityProbeCount); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0"; - var live = (long)(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L); + var live = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false); Interlocked.Exchange(ref _queuedRowCount, live); } + private async Task CountAsync(string predicate, CancellationToken ct) + { + var rows = await _db.QueryAsync( + $"SELECT COUNT(*) FROM alarm_sf_events WHERE {predicate}", + r => r.GetInt64(0), + parameters: null, + ct).ConfigureAwait(false); + return rows.Count > 0 ? rows[0] : 0L; + } + /// /// Read up to queued rows, forward through the writer, /// remove Ack'd rows, dead-letter PermanentFail rows, and extend the backoff /// on RetryPlease. Safe to call from multiple threads; the semaphore enforces /// serial execution. /// + /// + /// Returns without touching the queue when the drain gate is closed. On a redundant pair + /// that is the Secondary's steady state: its table fills by replication and stays put until + /// it is promoted. + /// /// Cancellation token for the operation. /// A task that represents the asynchronous operation. public async Task DrainOnceAsync(CancellationToken ct) { if (_disposed) return; - if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) return; + if (!await _drainGateLock.WaitAsync(0, ct).ConfigureAwait(false)) return; try { + if (!EvaluateDrainGate()) + { + lock (_statusLock) { _drainState = HistorianDrainState.NotPrimary; } + return; + } + lock (_statusLock) { _drainState = HistorianDrainState.Draining; _lastDrainUtc = _clock(); } - // One connection per drain tick — used by purge, read, corrupt-dead-letter, - // and the outcome-applying transaction. - using var conn = OpenConnection(); - - PurgeAgedDeadLetters(conn); - var batch = ReadBatch(conn); + await PurgeAgedDeadLettersAsync(ct).ConfigureAwait(false); + var batch = await ReadBatchAsync(ct).ConfigureAwait(false); if (batch.Count == 0) { lock (_statusLock) { _drainState = HistorianDrainState.Idle; } @@ -342,22 +341,24 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable } // A null/un-deserializable payload can never succeed — dead-letter it - // immediately for its own RowId so it cannot stall the queue head, and + // immediately for its own id so it cannot stall the queue head, and // exclude it from the batch handed to the writer. - var corruptRowIds = batch.Where(r => r.Event is null).Select(r => r.RowId).ToList(); + var corruptIds = batch.Where(r => r.Event is null).Select(r => r.Id).ToList(); var liveRows = batch.Where(r => r.Event is not null).ToList(); var events = liveRows.Select(r => r.Event!).ToList(); - if (corruptRowIds.Count > 0) + if (corruptIds.Count > 0) { - using var corruptTx = conn.BeginTransaction(); - foreach (var rowId in corruptRowIds) - DeadLetterRow(conn, corruptTx, rowId, $"corrupt payload at {_clock():O}"); - corruptTx.Commit(); - Interlocked.Add(ref _queuedRowCount, -corruptRowIds.Count); + await using (var corruptTx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false)) + { + foreach (var id in corruptIds) + await DeadLetterRowAsync(corruptTx, id, $"corrupt payload at {_clock():O}", ct).ConfigureAwait(false); + await corruptTx.CommitAsync(ct).ConfigureAwait(false); + } + Interlocked.Add(ref _queuedRowCount, -corruptIds.Count); _logger.Warning( "Dead-lettered {Count} historian queue row(s) with un-deserializable payload", - corruptRowIds.Count); + corruptIds.Count); } if (events.Count == 0) @@ -404,41 +405,44 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable } int rowsLeavingQueue = 0; - using (var tx = conn.BeginTransaction()) + await using (var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false)) { for (var i = 0; i < outcomes.Count; i++) { var outcome = outcomes[i]; - var rowId = liveRows[i].RowId; + var id = liveRows[i].Id; switch (outcome) { case HistorianWriteOutcome.Ack: - DeleteRow(conn, tx, rowId); + // Deleted, not marked delivered. The replication engine carries the + // delete as a tombstone, so the peer drops its copy and cannot + // re-deliver the row after a failover. + await DeleteRowAsync(tx, id, ct).ConfigureAwait(false); rowsLeavingQueue++; break; case HistorianWriteOutcome.PermanentFail: - DeadLetterRow(conn, tx, rowId, $"permanent fail at {_clock():O}"); + await DeadLetterRowAsync(tx, id, $"permanent fail at {_clock():O}", ct).ConfigureAwait(false); rowsLeavingQueue++; break; case HistorianWriteOutcome.RetryPlease: // finding 002: cap retries so a perpetually-RetryPlease (poison) // row cannot retry forever at the 60s backoff floor. The incoming - // AttemptCount is the count BEFORE this attempt; +1 accounts for the + // attempt count is the count BEFORE this attempt; +1 accounts for the // bump this drain represents. At the cap, dead-letter instead of // bumping — and count it as leaving the live queue like PermanentFail. if (liveRows[i].AttemptCount + 1 >= _maxAttempts) { - DeadLetterRow(conn, tx, rowId, $"max attempts ({_maxAttempts}) exceeded"); + await DeadLetterRowAsync(tx, id, $"max attempts ({_maxAttempts}) exceeded", ct).ConfigureAwait(false); rowsLeavingQueue++; } else { - BumpAttempt(conn, tx, rowId, "retry-please"); + await BumpAttemptAsync(tx, id, "retry-please", ct).ConfigureAwait(false); } break; } } - tx.Commit(); + await tx.CommitAsync(ct).ConfigureAwait(false); } // Ack-deleted + PermanentFail-dead-lettered rows both leave the // non-dead-lettered queue, as do RetryPlease rows that hit the max-attempts @@ -464,22 +468,66 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable } finally { - _drainGate.Release(); + _drainGateLock.Release(); } } + /// + /// Consults the injected gate, logging only when the decision changes. + /// + /// + /// Logged at all because the closed state is indistinguishable from a healthy idle queue + /// from the outside, and one way to reach it is a redundancy snapshot that never names this + /// node — the documented identity-mismatch shape. Without this line, that misconfiguration + /// would present as alarm history quietly ceasing on both nodes of a pair. Logged on the + /// transition rather than per tick because the drain runs every few seconds. + /// + /// true when this tick may proceed. + private bool EvaluateDrainGate() + { + bool open; + try + { + open = _drainGate(); + } + catch (Exception ex) + { + // A throwing gate must not wedge the queue silently, and must not be treated as + // permission either: skip this tick, say why, and re-ask on the next one. + _logger.Error(ex, "Historian drain gate threw; skipping this tick"); + return false; + } + + bool? previous; + lock (_statusLock) + { + previous = _lastGateDecision; + _lastGateDecision = open; + } + + if (previous == open) return open; + + if (open) + _logger.Information("Historian drain resumed — this node now services the alarm queue"); + else + _logger.Information( + "Historian drain suspended — this node is not the Primary. Queued alarm events are retained and will drain from whichever node holds the Primary role"); + + return open; + } + /// public HistorianSinkStatus GetStatus() { - if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink)); + if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink)); var queued = Interlocked.Read(ref _queuedRowCount); if (queued < 0) queued = 0; long deadlettered; - using (var conn = OpenConnection()) + using (var conn = _db.CreateConnection()) using (var cmd = conn.CreateCommand()) { - cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 1"; + cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 1"; deadlettered = (long)(cmd.ExecuteScalar() ?? 0L); } @@ -510,10 +558,13 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable /// The number of rows revived from the dead-letter state. public int RetryDeadLettered() { - if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink)); - using var conn = OpenConnection(); + if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink)); + // Through a LocalDb connection, not a private one: the capture triggers are what make this + // revival replicate to the peer, and they only exist on the registered table. + using var conn = _db.CreateConnection(); using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1"; + cmd.CommandText = + "UPDATE alarm_sf_events SET dead_lettered = 0, attempt_count = 0, last_error = NULL WHERE dead_lettered = 1"; var revived = cmd.ExecuteNonQuery(); // Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory // counter aligned. @@ -523,29 +574,31 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable /// /// One queued row paired with its deserialized event. is - /// null when the row's PayloadJson is corrupt or un-deserializable — - /// the always stays bound to its own row so outcomes can + /// null when the row's payload_json is corrupt or un-deserializable — + /// the always stays bound to its own row so outcomes can /// never be mapped to the wrong row. /// - private readonly record struct QueueRow(long RowId, AlarmHistorianEvent? Event, long AttemptCount); + private readonly record struct QueueRow(string Id, AlarmHistorianEvent? Event, long AttemptCount); - private List ReadBatch(SqliteConnection conn) + private async Task> ReadBatchAsync(CancellationToken ct) { - var rows = new List(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT RowId, PayloadJson, AttemptCount FROM Queue - WHERE DeadLettered = 0 - ORDER BY RowId ASC - LIMIT $limit - """; - cmd.Parameters.AddWithValue("$limit", _batchSize); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + // Ordered by enqueue time rather than insertion order: a hashed TEXT primary key carries + // no sequence the way the legacy AUTOINCREMENT rowid did. Round-trip ("O") timestamps sort + // lexicographically in chronological order; id makes the ordering total. + var raw = await _db.QueryAsync( + """ + SELECT id, payload_json, attempt_count FROM alarm_sf_events + WHERE dead_lettered = 0 + ORDER BY enqueued_at_utc ASC, id ASC + LIMIT @Limit + """, + r => (Id: r.GetString(0), Payload: r.GetString(1), AttemptCount: r.GetInt64(2)), + new { Limit = _batchSize }, + ct).ConfigureAwait(false); + + var rows = new List(raw.Count); + foreach (var (id, payload, attemptCount) in raw) { - var rowId = reader.GetInt64(0); - var payload = reader.GetString(1); - var attemptCount = reader.GetInt64(2); AlarmHistorianEvent? evt; try { @@ -556,77 +609,58 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable // Malformed JSON — carry a null event so the caller dead-letters this row. evt = null; } - rows.Add(new QueueRow(rowId, evt, attemptCount)); + rows.Add(new QueueRow(id, evt, attemptCount)); } return rows; } - private static void DeleteRow(SqliteConnection conn, SqliteTransaction tx, long rowId) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = "DELETE FROM Queue WHERE RowId = $id"; - cmd.Parameters.AddWithValue("$id", rowId); - cmd.ExecuteNonQuery(); - } + private static Task DeleteRowAsync(ILocalDbTransaction tx, string id, CancellationToken ct) => + tx.ExecuteAsync("DELETE FROM alarm_sf_events WHERE id = @Id", new { Id = id }, ct); - private void DeadLetterRow(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = """ - UPDATE Queue SET DeadLettered = 1, LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1 - WHERE RowId = $id - """; - cmd.Parameters.AddWithValue("$now", _clock().ToString("O")); - cmd.Parameters.AddWithValue("$err", reason); - cmd.Parameters.AddWithValue("$id", rowId); - cmd.ExecuteNonQuery(); - } + private Task DeadLetterRowAsync(ILocalDbTransaction tx, string id, string reason, CancellationToken ct) => + tx.ExecuteAsync( + """ + UPDATE alarm_sf_events + SET dead_lettered = 1, last_attempt_utc = @Now, last_error = @Err, + attempt_count = attempt_count + 1 + WHERE id = @Id + """, + new { Now = _clock().ToString("O"), Err = reason, Id = id }, + ct); - private void BumpAttempt(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = """ - UPDATE Queue SET LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1 - WHERE RowId = $id - """; - cmd.Parameters.AddWithValue("$now", _clock().ToString("O")); - cmd.Parameters.AddWithValue("$err", reason); - cmd.Parameters.AddWithValue("$id", rowId); - cmd.ExecuteNonQuery(); - } + private Task BumpAttemptAsync(ILocalDbTransaction tx, string id, string reason, CancellationToken ct) => + tx.ExecuteAsync( + """ + UPDATE alarm_sf_events + SET last_attempt_utc = @Now, last_error = @Err, attempt_count = attempt_count + 1 + WHERE id = @Id + """, + new { Now = _clock().ToString("O"), Err = reason, Id = id }, + ct); - // Async variant used by EnqueueAsync. - private async Task EnforceCapacityAsync(SqliteConnection conn, CancellationToken ct) + private async Task EnforceCapacityAsync(CancellationToken ct) { Interlocked.Increment(ref _capacityProbeCount); - long count; - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0"; - count = (long)(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L); - } + var count = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false); + // Resync the in-memory counter while we have a fresh number. Interlocked.Exchange(ref _queuedRowCount, count); if (count < _capacity) return; var toEvict = count - _capacity + 1; - using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = """ - DELETE FROM Queue - WHERE RowId IN ( - SELECT RowId FROM Queue - WHERE DeadLettered = 0 - ORDER BY RowId ASC - LIMIT $n - ) - """; - cmd.Parameters.AddWithValue("$n", toEvict); - await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); - } + await _db.ExecuteAsync( + """ + DELETE FROM alarm_sf_events + WHERE id IN ( + SELECT id FROM alarm_sf_events + WHERE dead_lettered = 0 + ORDER BY enqueued_at_utc ASC, id ASC + LIMIT @N + ) + """, + new { N = toEvict }, + ct).ConfigureAwait(false); + Interlocked.Add(ref _queuedRowCount, -toEvict); long lifetimeEvicted; lock (_statusLock) { _evictedCount += toEvict; lifetimeEvicted = _evictedCount; } @@ -635,40 +669,21 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable _capacity, toEvict, lifetimeEvicted); } - private void PurgeAgedDeadLetters(SqliteConnection conn) + private async Task PurgeAgedDeadLettersAsync(CancellationToken ct) { var cutoff = (_clock() - _deadLetterRetention).ToString("O"); - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - DELETE FROM Queue - WHERE DeadLettered = 1 AND LastAttemptUtc IS NOT NULL AND LastAttemptUtc < $cutoff - """; - cmd.Parameters.AddWithValue("$cutoff", cutoff); - var purged = cmd.ExecuteNonQuery(); + var purged = await _db.ExecuteAsync( + """ + DELETE FROM alarm_sf_events + WHERE dead_lettered = 1 AND last_attempt_utc IS NOT NULL AND last_attempt_utc < @Cutoff + """, + new { Cutoff = cutoff }, + ct).ConfigureAwait(false); + if (purged > 0) _logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged); } - private void InitializeSchema() - { - using var conn = OpenConnection(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - CREATE TABLE IF NOT EXISTS Queue ( - RowId INTEGER PRIMARY KEY AUTOINCREMENT, - AlarmId TEXT NOT NULL, - EnqueuedUtc TEXT NOT NULL, - PayloadJson TEXT NOT NULL, - AttemptCount INTEGER NOT NULL DEFAULT 0, - LastAttemptUtc TEXT NULL, - LastError TEXT NULL, - DeadLettered INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId); - """; - cmd.ExecuteNonQuery(); - } - private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1); private void ResetBackoff() => _backoffIndex = 0; @@ -676,12 +691,16 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex]; /// Disposes the sink and releases all held resources including the drain timer and the writer. + /// + /// The is NOT disposed here — it is a shared singleton owned by the + /// host and used by the deployment cache too. + /// public void Dispose() { if (_disposed) return; _disposed = true; _drainTimer?.Dispose(); - _drainGate.Dispose(); + _drainGateLock.Dispose(); if (_writer is IDisposable writerDisposable) writerDisposable.Dispose(); } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj index 62515df4..7e225b8d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj @@ -20,6 +20,12 @@ --> + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs index 02eb624a..1817c8a3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// /// backed by the HistorianGateway SendEvent path. The -/// drain worker behind SqliteStoreAndForwardSink calls +/// drain worker behind LocalDbStoreAndForwardSink calls /// and uses the returned per-event /// to decide retry vs. dead-letter, so this writer maps every /// gateway result — success ack, the published client's typed exception hierarchy, raw diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs index 6033bde7..4df51b33 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -47,7 +47,7 @@ public static class GatewayHistorian /// — the same single gateway the read path /// () targets. The Host's AddAlarmHistorian wiring supplies /// this as the concrete the durable - /// SqliteStoreAndForwardSink drain worker delegates to, sourcing the connection from the + /// LocalDbStoreAndForwardSink drain worker delegates to, sourcing the connection from the /// ServerHistorian section (endpoint/key/TLS) rather than the legacy Wonderware-shaped /// AlarmHistorian host/port. Resolves an and the writer's /// from , falling back to the null diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs index 5f429f30..c7a323d1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs @@ -57,16 +57,23 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi $"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'."); } + // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects + // each of them outright when UseTls=false (" is a TLS-only option and requires + // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer- + // Certificate defaults to false, so RequireCertificateValidation was always sent as true and + // every http:// deployment crashed at startup, even though the scheme is documented as the + // supported way to select h2c. There is no certificate to have a posture about here. var clientOptions = new HistorianGatewayClientOptions { Endpoint = endpointUri, ApiKey = options.ApiKey, UseTls = options.UseTls, - CaCertificatePath = options.CaCertificatePath, - // INVERTED mapping: ServerHistorianOptions.AllowUntrustedServerCertificate (opt-in to accept - // a self-signed cert) is the negation of the client's RequireCertificateValidation. Allowing - // an untrusted cert == not requiring validation; a pinned CaCertificatePath always verifies. - RequireCertificateValidation = !options.AllowUntrustedServerCertificate, + CaCertificatePath = options.UseTls ? options.CaCertificatePath : null, + // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate + // (opt-in to accept a self-signed cert) is the negation of the client's + // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a + // pinned CaCertificatePath always verifies. + RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate, DefaultCallTimeout = options.CallTimeout, LoggerFactory = loggerFactory, }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor index db03988c..a957ca51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -18,8 +18,9 @@
Snapshot from the local node's HistorianAdapterActor. Default sink is a no-op (NullAlarmHistorianSink); production wires - SqliteStoreAndForwardSink draining to the HistorianGateway - (SendEvent) behind it. Polling every @PollSeconds s. + LocalDbStoreAndForwardSink — buffering into this node's LocalDb, and + draining to the HistorianGateway (SendEvent) only while this node holds + the Primary role. Polling every @PollSeconds s.
@if (_status is null) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs new file mode 100644 index 00000000..8e6cc31b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs @@ -0,0 +1,217 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// One-time copy of the pre-consolidation alarm-historian.db store-and-forward queue +/// into the consolidated ZB.MOM.WW.LocalDb database. +/// +/// +/// +/// What is at stake. Rows sit in that file precisely because the historian could not +/// be reached. Discarding them on upgrade would throw away exactly the alarm audit trail +/// the store-and-forward queue exists to protect — so the copy tolerates an older column +/// set rather than bailing out, and refuses to rename a file it did not actually read. +/// +/// +/// Runs after RegisterReplicated, deliberately. Capture is trigger-based, so +/// rows inserted before registration never enter the oplog and never reach the peer — +/// silently, and permanently, because nothing revisits history. Migrating after +/// registration means the recovered backlog replicates like any other write. +/// +/// +/// Ids come from the payload, not from the legacy row id. The legacy primary key was +/// RowId INTEGER PRIMARY KEY AUTOINCREMENT, which cannot survive into a replicated +/// table: each node allocated its own sequence, so node A's row 7 and node B's row 7 are +/// different alarms that would silently overwrite one another under last-writer-wins. +/// Hashing the payload () solves that and one more +/// problem besides — a warm pair's two legacy files overlap, because +/// HistorianAdapterActor default-writes while its redundancy role is unknown and +/// both nodes therefore accepted the same transitions during every boot window. A +/// node-prefixed id would carry that duplication into the merged buffer forever; an +/// equal-payload id collapses it. It is also what makes a re-run harmless under +/// INSERT OR IGNORE. +/// +/// +/// All-or-nothing. The copy runs in one transaction and the legacy file is renamed +/// to <name>.migrated only after the commit. A failure throws out of +/// OnReady, failing host startup with the legacy file untouched — no half-migrated +/// state to reason about, and the operator still holds the original. A later boot sees the +/// renamed file and no-ops. +/// +/// +public static class AlarmSfLegacyMigrator +{ + private const string MigratedSuffix = ".migrated"; + + /// The legacy queue table. + private const string LegacyTable = "Queue"; + + /// + /// The configuration key that used to carry the standalone queue file's path. Read as a raw + /// key because the corresponding AlarmHistorianOptions property was removed with the + /// bespoke file management it configured. + /// + public const string LegacyPathKey = "AlarmHistorian:DatabasePath"; + + /// The pre-removal default for . + private const string DefaultLegacyPath = "alarm-historian.db"; + + /// + /// The legacy column whose absence means this is not a queue file we can read. Without the + /// payload there is no event and no id to derive from one. + /// + private const string RequiredColumn = "PayloadJson"; + + /// + /// Every legacy column, mapped to its consolidated counterpart. Columns absent from an + /// older file are dropped from the copy rather than failing it — see + /// . + /// + private static readonly (string Legacy, string Current)[] ColumnMap = + [ + ("AlarmId", "alarm_id"), + ("EnqueuedUtc", "enqueued_at_utc"), + ("PayloadJson", "payload_json"), + ("AttemptCount", "attempt_count"), + ("LastAttemptUtc", "last_attempt_utc"), + ("LastError", "last_error"), + ("DeadLettered", "dead_lettered"), + ]; + + /// + /// Copies any legacy alarm queue into , then renames the legacy file. + /// + /// The consolidated database, with alarm_sf_events already registered. + /// Configuration supplying the legacy queue's path. + public static void Migrate(ILocalDb db, IConfiguration config) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(config); + + var legacyPath = ResolveLegacyPath(config); + if (!ShouldMigrate(legacyPath)) return; + + using (var legacy = OpenLegacyReadOnly(legacyPath)) + { + var present = PresentColumns(legacy); + + // An absent table probes as zero columns, so this one guard covers both "no queue + // table" and "a shape we do not recognise". Returning without renaming leaves the file + // for an operator to inspect. + if (!present.Contains(RequiredColumn)) return; + + using var connection = db.CreateConnection(); + using var transaction = connection.BeginTransaction(); + CopyRows(legacy, connection, transaction, present); + transaction.Commit(); + } + + // Only after the commit. A crash between the two leaves the file in place and the next + // boot copies it again, which the payload-derived ids make harmless. + File.Move(legacyPath, legacyPath + MigratedSuffix, overwrite: true); + } + + /// + /// Resolves the legacy queue path from the removed configuration key, falling back to the + /// code default it used to carry. + /// + /// + /// Relative paths resolve against the process working directory — not the LocalDb + /// directory — because that is where the old code actually put them. + /// + /// The application configuration. + /// An absolute path, or empty when there is nothing durable to migrate from. + internal static string ResolveLegacyPath(IConfiguration config) + { + var path = config[LegacyPathKey] ?? DefaultLegacyPath; + + // In-memory and URI-form sources (test / dev configurations) have nothing durable behind + // them, and Path.GetFullPath on them would produce nonsense. + if (string.IsNullOrWhiteSpace(path) || + path.Equals(":memory:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + return Path.GetFullPath(path); + } + + /// Whether there is a legacy file worth opening. + private static bool ShouldMigrate(string legacyPath) => + !string.IsNullOrEmpty(legacyPath) + && File.Exists(legacyPath) + && !File.Exists(legacyPath + MigratedSuffix); + + /// Opens the legacy file read-only, so a failed migration cannot damage it. + private static SqliteConnection OpenLegacyReadOnly(string path) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadOnly, + }.ToString()); + connection.Open(); + return connection; + } + + /// + /// Which of the columns we know about the legacy table actually has. + /// + /// + /// A file written by an older build predates some columns, and naming a missing one in the + /// SELECT throws "no such column" — which discards every row in the table rather than the + /// one field. Intersecting first means an old file migrates its data and simply leaves the + /// newer columns at their schema defaults. + /// + private static HashSet PresentColumns(SqliteConnection legacy) + { + var present = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var cmd = legacy.CreateCommand(); + cmd.CommandText = $"SELECT name FROM pragma_table_info('{LegacyTable}')"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + present.Add(reader.GetString(0)); + + return present; + } + + /// Copies every legacy row into the consolidated table, keyed by payload hash. + private static void CopyRows( + SqliteConnection legacy, + SqliteConnection target, + SqliteTransaction transaction, + HashSet present) + { + var columns = ColumnMap.Where(c => present.Contains(c.Legacy)).ToArray(); + var payloadOrdinal = Array.FindIndex(columns, c => c.Legacy == RequiredColumn); + + using var read = legacy.CreateCommand(); + read.CommandText = + $"SELECT {string.Join(", ", columns.Select(c => c.Legacy))} FROM {LegacyTable}"; + + using var reader = read.ExecuteReader(); + while (reader.Read()) + { + using var insert = target.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = $""" + INSERT OR IGNORE INTO {AlarmSfSchema.EventsTable} + ({AlarmSfSchema.IdColumn}, {string.Join(", ", columns.Select(c => c.Current))}) + VALUES + ($id, {string.Join(", ", columns.Select((_, i) => $"$c{i}"))}) + """; + + insert.Parameters.AddWithValue("$id", AlarmSfSchema.DeriveId(reader.GetString(payloadOrdinal))); + for (var i = 0; i < columns.Length; i++) + insert.Parameters.AddWithValue($"$c{i}", reader.GetValue(i) ?? DBNull.Value); + + insert.ExecuteNonQuery(); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs index c23fa6bc..aa7522ec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs @@ -88,7 +88,7 @@ public static class LocalDbRegistration ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - services.AddZbLocalDb(configuration, LocalDbSetup.OnReady); + services.AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)); services.AddZbLocalDbReplication(configuration); // The consumer-facing seam. WithOtOpcUaRuntimeActors resolves this optionally and threads it diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs index 0ce2d3be..ecbd6c8a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs @@ -1,11 +1,13 @@ +using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// -/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache -/// tables and opts them into replication. +/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache and +/// alarm store-and-forward tables and opts them into replication. /// /// /// @@ -26,23 +28,41 @@ public static class LocalDbSetup /// RegisterReplicated is what installs the three AFTER triggers that capture /// changes into the oplog. Any row written before that call is never captured, so it /// never reaches the peer — silently, and permanently, because nothing ever revisits - /// history. Phase 1 writes nothing here; when Phase 2 adds its store-and-forward - /// migrator, the migrator must run after both registrations for the same reason. + /// history. therefore runs last, after every + /// registration — it writes rows, and they must be captured like any other write. + /// + /// + /// The alarm buffer's tables are created unconditionally, regardless of whether this + /// node has AlarmHistorian:Enabled set. An empty registered table costs three + /// triggers and nothing else, whereas creating it lazily when the sink first appears + /// would mean a node that enables the historian later writes rows before its triggers + /// exist — which is precisely the silent-loss shape above. /// /// /// The freshly constructed local database. - public static void OnReady(ILocalDb db) + /// + /// Application configuration, read by the legacy migrator for the pre-consolidation queue's + /// path. Required rather than optional: an overload that silently skipped the migration + /// would be one wiring mistake away from discarding a node's undelivered alarm history. + /// + public static void OnReady(ILocalDb db, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(configuration); // CreateConnection() hands back an already-open, pragma-configured connection carrying the // zb_hlc_next() UDF the capture triggers need. Calling Open() on it would throw. using (var connection = db.CreateConnection()) { DeploymentCacheSchema.Apply(connection); + AlarmSfSchema.Apply(connection); } db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable); db.RegisterReplicated(DeploymentCacheSchema.PointerTable); + db.RegisterReplicated(AlarmSfSchema.EventsTable); + + // LAST, and only here. This is the one call in OnReady that writes rows. + AlarmSfLegacyMigrator.Migrate(db, configuration); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs index ca727a5f..a9ebcc86 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -24,12 +24,25 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// in the Host, so Enabled covers it. /// /// -/// Fail tier = provably-crashing configs only. Only an empty / non-absolute / non-http(s) -/// Endpoint fails here (it throws in the factory). Empty ApiKey and non-positive -/// MaxTieClusterOverfetch degrade rather than crash (the gateway rejects calls / the node -/// manager surfaces a Bad read), so they stay operator warnings in +/// Fail tier = provably-crashing configs only. Three settings qualify, and they all fail +/// the same way — HistorianGatewayClientOptions.Validate() throws inside the client +/// factory before any call is attempted, so the host dies at startup: +/// an empty / non-absolute / non-http(s) Endpoint; an empty ApiKey ("The gateway +/// API key must not be empty"); and a UseTls that disagrees with the endpoint scheme +/// ("UseTls requires an https gateway endpoint" / "An https gateway endpoint requires UseTls"). +/// A non-positive MaxTieClusterOverfetch genuinely degrades rather than crashes (the node +/// manager surfaces a Bad read), so it stays an operator warning in /// . The Endpoint value is not a secret and -/// is echoed to make the error actionable; the ApiKey is never surfaced. +/// is echoed to make each error actionable; the ApiKey is never surfaced. +/// +/// +/// The ApiKey and UseTls checks were added after two consecutive live-rig +/// crash-loops (LocalDb Phase 2 gate). Both had been classified as degrading, on the +/// assumption that a bad client configuration would connect and be rejected by the gateway — but +/// the client validates its own options at construction, so the process dies during Akka startup +/// instead, which is precisely the failure this validator exists to convert into a named, +/// aggregated error. The lesson generalises: "degrades" is only true of settings the client +/// does not itself validate. /// /// public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase @@ -62,5 +75,26 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase.
private readonly Func? _driverMemberCountProvider; + /// Where the Primary-gate decision is published for consumers that live outside a mailbox — + /// today, the alarm store-and-forward drain. Null on nodes that do not wire one. + private readonly IRedundancyRoleView? _redundancyRoleView; + + /// + /// Host of this node's LocalDb replication partner — the only node that holds a copy of this + /// node's alarm queue, and therefore the only node it may ever stand down in favour of. + /// null when this node dials nobody (unpaired, or the listening half of a pair). + /// + private readonly string? _replicationPeerHost; + /// Debounces the S5 "snapshot omitted this node" Warning to once per process — a persistent /// identity mismatch (03/S5) would otherwise log on every snapshot. private bool _warnedSnapshotMissingLocalNode; @@ -366,7 +378,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers IActorRef? scriptedAlarmHostOverride = null, IDriverCapabilityInvokerFactory? invokerFactory = null, Func? driverMemberCountProvider = null, - IDeploymentArtifactCache? deploymentArtifactCache = null) => + IDeploymentArtifactCache? deploymentArtifactCache = null, + IRedundancyRoleView? redundancyRoleView = null, + string? replicationPeerHost = null) => // WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // expression tree. Six IActorRef? parameters and several interface-typed ones mean a // mis-ordered argument is usually type-compatible and therefore compiles clean, then binds @@ -376,7 +390,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride, loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, - deploymentArtifactCache)); + deploymentArtifactCache, redundancyRoleView, replicationPeerHost)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -426,9 +440,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers IActorRef? scriptedAlarmHostOverride = null, IDriverCapabilityInvokerFactory? invokerFactory = null, Func? driverMemberCountProvider = null, - IDeploymentArtifactCache? deploymentArtifactCache = null) + IDeploymentArtifactCache? deploymentArtifactCache = null, + IRedundancyRoleView? redundancyRoleView = null, + string? replicationPeerHost = null) { _deploymentArtifactCache = deploymentArtifactCache; + _redundancyRoleView = redundancyRoleView; + _replicationPeerHost = replicationPeerHost; _dbFactory = dbFactory; _localNode = localNode; _coordinatorOverride = coordinator; @@ -1457,6 +1475,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } + /// Host part of a host:port node id. + private static string HostOf(NodeId nodeId) + { + var text = nodeId.ToString(); + var colon = text.LastIndexOf(':'); + return colon < 0 ? text : text[..colon]; + } + /// The Primary-gate deny reason tag for the denial meter (secondary|detached|role-unknown). private string PrimaryGateDenyReason() => _localRole switch { @@ -1476,19 +1502,35 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (local is not null) { _localRole = local.Role; - return; } - - // S5 diagnostic: the snapshot never mentioned this node. On a multi-driver cluster that means the - // Primary gate stays default-DENY indefinitely for an unknown role — surface it once so a silent - // identity mismatch (e.g. an ApplicationUri/NodeId skew) is diagnosable. - if (!_warnedSnapshotMissingLocalNode && DriverMemberCount() > 1) + else if (!_warnedSnapshotMissingLocalNode && DriverMemberCount() > 1) { + // S5 diagnostic: the snapshot never mentioned this node. On a multi-driver cluster that means the + // Primary gate stays default-DENY indefinitely for an unknown role — surface it once so a silent + // identity mismatch (e.g. an ApplicationUri/NodeId skew) is diagnosable. _warnedSnapshotMissingLocalNode = true; _log.Warning( "DriverHost {Node}: redundancy snapshot omitted this node (snapshotNodes={SnapshotNodes}); Primary data-plane gate stays default-DENY while role is unknown on a multi-driver cluster", _localNode, string.Join(",", msg.Nodes.Select(n => n.NodeId))); } + + // Publish on EVERY snapshot, including ones that leave the cached role untouched, so a role + // that changes without this node being named still reaches the drain within one heartbeat. + // + // NOTE the different policy: the drain gate keys on the role ALONE and opens when it is + // unknown, where the data-plane gate above resolves an unknown role by member count and + // denies. Publishing ShouldServiceAsPrimary() here suspended the drain on every node of a + // multi-driver cluster that had no redundancy configured — see ShouldDrainAlarmHistory. + // Defer ONLY to the node that holds a copy of our rows. The redundancy election is + // cluster-wide and the queue is pair-local, so "somebody is Primary" is not a licence to stop + // draining — that Primary is usually in another pair entirely, and cannot deliver our events. + var peerIsPrimary = + _replicationPeerHost is not null + && msg.Nodes.Any(n => n.Role == RedundancyRole.Primary + && HostOf(n.NodeId) == _replicationPeerHost); + + _redundancyRoleView?.Publish( + PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary)); } private void Stale() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs index 90486a19..86a0e3bf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs @@ -25,4 +25,50 @@ public static class PrimaryGatePolicy RedundancyRole.Secondary or RedundancyRole.Detached => false, _ => driverMemberCount <= 1, // unknown: allow only when no driver peer exists }; + + /// + /// Decide whether this node should drain the replicated alarm store-and-forward queue. + /// + /// + /// + /// Deliberately not . That gate protects a shared + /// field device, where two nodes acting at once is dangerous and irreversible, so an unknown + /// role with a peer present must deny. This gate protects an append-only historian, and the + /// delivery contract is already at-least-once across a failover, by design — identical + /// events even collapse to a single row, because ids hash the payload. + /// + /// + /// The two error costs are therefore asymmetric: a false allow costs a duplicate history row; + /// a false deny silently stops the alarm audit trail and eventually evicts it at the capacity + /// wall. So this decision keys on the role alone and opens when the role is unknown — + /// no membership tie-break, because cluster-wide driver count says nothing about whether + /// this node has a redundant partner. + /// + /// + /// Found by the LocalDb Phase 2 live gate: on a rig of four driver nodes in one cluster with + /// no Redundancy section, borrowing the write gate's verdict suspended the drain on + /// every node — including unpaired ones — so nothing drained anywhere. + /// + /// + /// This node's last-known redundancy role, or null when unknown. + /// + /// Whether the node holding in the same snapshot is a node + /// that shares this node's queue — i.e. its LocalDb replication partner. + /// + /// Merely knowing that a Primary exists is not enough, because the redundancy role is + /// a CLUSTER-WIDE election while the alarm queue is PAIR-LOCAL. A fleet runs many pairs in + /// one cluster, so the elected driver Primary is usually in some other pair — and on the + /// docker-dev rig it is a central node, which carries the driver Akka role, replicates + /// nobody's LocalDb and does not even run the alarm historian. Deferring to it means this + /// node's events are delivered by no one, ever. + /// + /// + /// true to drain; false only when a node holding these same rows is doing it. + public static bool ShouldDrainAlarmHistory(RedundancyRole? localRole, bool queueSharingPeerIsPrimary) => + localRole switch + { + RedundancyRole.Primary => true, + RedundancyRole.Secondary or RedundancyRole.Detached => !queueSharingPeerIsPrimary, + _ => true, // unknown role ⇒ drain; a duplicate row beats a silent gap + }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs index 6bced110..9d7ed4a7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.IO; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; @@ -7,11 +6,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// /// Binds the AlarmHistorian configuration section that gates the durable /// store-and-forward alarm sink. When is true, -/// AddAlarmHistorian registers a SqliteStoreAndForwardSink (draining to the +/// AddAlarmHistorian registers a LocalDbStoreAndForwardSink (draining to the /// gateway alarm writer supplied by the Host) in place of the /// NullAlarmHistorianSink default; otherwise the Null default survives. This section -/// supplies only the gate and the SQLite store-and-forward knobs — the -/// downstream connection (endpoint/key/TLS) is sourced from the ServerHistorian section. +/// supplies only the gate and the store-and-forward knobs — the +/// downstream connection (endpoint/key/TLS) is sourced from the ServerHistorian section, +/// and the queue's storage location is the node's consolidated LocalDb:Path database. /// public sealed class AlarmHistorianOptions { @@ -24,9 +24,6 @@ public sealed class AlarmHistorianOptions ///
public bool Enabled { get; init; } - /// Filesystem path to the local SQLite store-and-forward queue database. - public string DatabasePath { get; init; } = "alarm-historian.db"; - /// Maximum number of queued rows the drain worker forwards in a single batch. public int BatchSize { get; init; } = 100; @@ -34,15 +31,15 @@ public sealed class AlarmHistorianOptions public int DrainIntervalSeconds { get; init; } = 5; /// Maximum queued rows before the sink evicts the oldest. Defaults to 1,000,000 - /// (matches SqliteStoreAndForwardSink's DefaultCapacity). - public long Capacity { get; init; } = SqliteStoreAndForwardSink.DefaultCapacity; + /// (matches LocalDbStoreAndForwardSink's DefaultCapacity). + public long Capacity { get; init; } = LocalDbStoreAndForwardSink.DefaultCapacity; /// Days to retain dead-lettered rows before purge. Defaults to 30. public int DeadLetterRetentionDays { get; init; } = 30; /// Maximum delivery attempts before a perpetually-retrying (poison) row is dead-lettered. - /// Defaults to 10 (matches SqliteStoreAndForwardSink's DefaultMaxAttempts). - public int MaxAttempts { get; init; } = SqliteStoreAndForwardSink.DefaultMaxAttempts; + /// Defaults to 10 (matches LocalDbStoreAndForwardSink's DefaultMaxAttempts). + public int MaxAttempts { get; init; } = LocalDbStoreAndForwardSink.DefaultMaxAttempts; /// Returns operator-facing misconfiguration warnings for an Enabled historian /// (empty when disabled or correctly configured). Pure — the registration logs each entry. @@ -51,8 +48,6 @@ public sealed class AlarmHistorianOptions { var warnings = new List(); if (!Enabled) return warnings; - if (!Path.IsPathRooted(DatabasePath)) - warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path."); if (DrainIntervalSeconds <= 0) warnings.Add($"AlarmHistorian:DrainIntervalSeconds is {DrainIntervalSeconds} — must be > 0; the drain timer will throw or spin at startup."); if (Capacity <= 0) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs index 93eb4142..af60b86b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// Thin actor wrapper around . Engine code (ScriptedAlarmActor, /// Galaxy native alarm bridge, AB CIP ALMD reader) tells s to this /// actor; the actor enqueues them on the sink fire-and-forget. Production deployments register -/// against IAlarmHistorianSink; the sink owns the +/// against IAlarmHistorianSink; the sink owns the /// durable queue + drain-to-HistorianGateway-SendEvent loop. The actor here owns nothing operational beyond /// the message contract — its job is to keep the engine actors on Akka's mailbox without blocking /// them on disk I/O or gateway round-trips. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs new file mode 100644 index 00000000..a9c41dff --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs @@ -0,0 +1,57 @@ +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; + +/// +/// A singleton snapshot of the alarm-history drain decision, so code that lives outside an actor +/// can ask "should this node be draining the alarm queue right now?". +/// +/// +/// +/// Exists for the alarm store-and-forward drain. That drain runs on a timer owned by the +/// sink, not on an actor mailbox, so it cannot receive RedundancyStateChanged +/// directly — but it must be role-scoped, because the queue it drains replicates to the +/// pair peer and two draining nodes would deliver every event twice. +/// +/// +/// Deliberately publishes the decision rather than the role that produced it, so +/// stays the single place that decides. +/// +/// +/// This is not the device-write gate. It is fed by +/// , which opens on an unknown role +/// where closes. The naming is +/// explicit about the consumer for that reason: an earlier revision published the +/// write-gate verdict here, and on a cluster with several driver nodes but no configured +/// redundancy it suspended the drain everywhere, so alarm history accumulated on every node +/// and left none. +/// +/// +public interface IRedundancyRoleView +{ + /// Whether this node should be draining the alarm store-and-forward queue right now. + bool ShouldDrainAlarmHistory { get; } + + /// Records a fresh decision. Called by DriverHostActor on every redundancy snapshot. + /// + /// The decision produced. + /// + void Publish(bool shouldDrainAlarmHistory); +} + +/// Thread-safe backed by a volatile field. +public sealed class RedundancyRoleView : IRedundancyRoleView +{ + // Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a + // node whose role nothing ever reports are genuinely the same situation, and they must behave the + // same: a deployment that runs no redundancy at all never publishes here, and defaulting closed + // would silently stop its alarm history forever. + private volatile bool _shouldDrainAlarmHistory = + PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: false); + + /// + public bool ShouldDrainAlarmHistory => _shouldDrainAlarmHistory; + + /// + public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 0cddd870..9cdbeddb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -20,7 +20,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Health; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; +using ZB.MOM.WW.LocalDb; namespace ZB.MOM.WW.OtOpcUa.Runtime; @@ -39,7 +41,7 @@ public static class ServiceCollectionExtensions /// /// Registers shared runtime services. Currently binds /// to as the default; production deployments - /// override this with SqliteStoreAndForwardSink wrapping the HistorianGateway alarm writer. + /// override this with LocalDbStoreAndForwardSink wrapping the HistorianGateway alarm writer. /// Call this BEFORE AddAkka. /// /// The service collection to register with. @@ -65,12 +67,23 @@ public static class ServiceCollectionExtensions /// /// Config-gated durable alarm-historian sink. When the AlarmHistorian section has - /// Enabled=true, registers a (draining via the + /// Enabled=true, registers a (draining via the /// -supplied writer) as the , /// overriding the default. Otherwise a no-op (Null stays). /// The writer is injected so the durable downstream (the HistorianGateway alarm writer) can be /// supplied by the Host, which is the only project that references it. /// + /// + /// + /// The queue lives in the node's consolidated , which replicates it + /// to the redundant pair peer — so undelivered alarm history survives losing the node + /// holding it. That is also why the drain is gated on : + /// the Secondary holds a full replica of the Primary's queue, and an ungated drain there + /// would re-deliver every event. Both services are resolved from the provider, so a + /// deployment that enables this section without registering LocalDb fails loudly at + /// resolution rather than silently buffering to nowhere. + /// + /// /// The service collection to register with. /// The configuration carrying the AlarmHistorian section. /// @@ -87,21 +100,56 @@ public static class ServiceCollectionExtensions if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime foreach (var warning in opts.Validate()) - Serilog.Log.Logger.ForContext().Warning("Historian config: {HistorianConfigWarning}", warning); + Serilog.Log.Logger.ForContext().Warning("Historian config: {HistorianConfigWarning}", warning); + + // The view is a plain singleton so it exists even on nodes whose DriverHostActor never + // publishes to it — an unpublished view reads as "no peer, drain", which is the correct + // posture for a deployment that runs no redundancy at all. + services.TryAddSingleton(); + + // THE GATE ONLY APPLIES TO A REPLICATED QUEUE. Standing down is only ever safe because some + // other node holds the same rows and will send them instead — and the only node that does is + // this node's LocalDb replication peer. Without replication configured, these rows exist here + // and nowhere else, so deferring to anyone means they are never delivered by anyone. + // + // This is not hypothetical. The redundancy role is a CLUSTER-WIDE election + // (RedundancyStateActor keys on Akka's RoleLeader("driver")), while the queue is PAIR-LOCAL. + // On the docker-dev rig the elected driver Primary is a central node — which carries the + // driver Akka role, replicates nobody's LocalDb, and does not even run the alarm historian — + // so every site node dutifully suspended its drain in favour of a node that could not + // possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what + // keeps the two scopes from disagreeing. + // BOTH halves of a pair share the queue, but only one of them dials: the initiator sets + // Replication:PeerAddress, its partner only sets SyncListenPort and waits. Testing the dial + // side alone would leave the listening half permanently ungated — one drainer per pair by + // accident rather than by role, and the wrong one whenever the roles swap. + var replicated = + !string.IsNullOrWhiteSpace(configuration["LocalDb:Replication:PeerAddress"]) + || !string.IsNullOrWhiteSpace(configuration["LocalDb:SyncListenPort"]); + + if (!replicated) + { + Serilog.Log.Logger.ForContext().Information( + "Alarm historian: LocalDb replication is not configured, so this node's queue is not " + + "shared with any peer and the Primary drain gate does not apply — this node always " + + "drains its own alarm queue."); + } services.AddSingleton(sp => { - // SqliteStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging). + // LocalDbStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging). // Resolve it off the host's configured static logger so the drain worker's WARN/INFO // lines land in the same sinks as the rest of the process. - var sink = new SqliteStoreAndForwardSink( - opts.DatabasePath, + var roleView = sp.GetRequiredService(); + var sink = new LocalDbStoreAndForwardSink( + sp.GetRequiredService(), writerFactory(opts, sp), - Serilog.Log.Logger.ForContext(), + Serilog.Log.Logger.ForContext(), batchSize: opts.BatchSize, capacity: opts.Capacity, deadLetterRetention: TimeSpan.FromDays(opts.DeadLetterRetentionDays), - maxAttempts: opts.MaxAttempts); + maxAttempts: opts.MaxAttempts, + drainGate: () => !replicated || roleView.ShouldDrainAlarmHistory); sink.StartDrainLoop(TimeSpan.FromSeconds(opts.DrainIntervalSeconds)); return sink; }); @@ -227,6 +275,21 @@ public static class ServiceCollectionExtensions // harnesses) rather than given a null-object, so DriverHostActor skips caching outright // instead of pretending to cache into a sink that drops everything. var deploymentArtifactCache = resolver.GetService(); + // Where this actor publishes its Primary-gate verdict for the alarm store-and-forward + // drain, which runs on a timer and so cannot read RedundancyStateChanged itself. + // Registered by AddAlarmHistorian; absent when no durable sink is configured, in which + // case there is nothing downstream to inform. + var redundancyRoleView = resolver.GetService(); + // Host of this node's LocalDb replication partner: the ONLY node that holds a copy of this + // node's alarm queue, and so the only node it may stand down in favour of. Null when this + // node dials nobody, which correctly means "never stand down". + var replicationPeerHost = + Uri.TryCreate( + resolver.GetService()?["LocalDb:Replication:PeerAddress"], + UriKind.Absolute, + out var peerUri) + ? peerUri.Host + : null; // Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in // Host DI inside the hasDriver block; may be absent in some role configs / test harnesses, // in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host. @@ -345,7 +408,9 @@ public static class ServiceCollectionExtensions loggerFactory: loggerFactory, scriptRootLogger: scriptRootLogger, invokerFactory: invokerFactory, - deploymentArtifactCache: deploymentArtifactCache), + deploymentArtifactCache: deploymentArtifactCache, + redundancyRoleView: redundancyRoleView, + replicationPeerHost: replicationPeerHost), DriverHostActorName); registry.Register(driverHost); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/LocalDbStoreAndForwardSinkTests.cs similarity index 77% rename from tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs rename to tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/LocalDbStoreAndForwardSinkTests.cs index 3f86e70f..da64ddf4 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/LocalDbStoreAndForwardSinkTests.cs @@ -1,9 +1,12 @@ using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Serilog; using Serilog.Core; using Serilog.Events; using Shouldly; using Xunit; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests; @@ -14,22 +17,66 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests; /// capacity eviction, and retention-based dead-letter purge. /// [Trait("Category", "Unit")] -public sealed class SqliteStoreAndForwardSinkTests : IDisposable +public sealed class LocalDbStoreAndForwardSinkTests : IDisposable { private readonly string _dbPath; private readonly ILogger _log; + private ServiceProvider? _provider; + private ILocalDb? _db; /// Initializes a new test instance. - public SqliteStoreAndForwardSinkTests() + public LocalDbStoreAndForwardSinkTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite"); _log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger(); } + /// + /// A real temp-file local database with the buffer table created and registered, built on + /// first use. + /// + /// + /// A real database rather than a stub. The library has no in-memory mode, and a bare + /// would lack the zb_hlc_next() UDF the capture + /// triggers call — so every write to a registered table would fail closed. Registering here + /// also means these tests exercise the same capture path production replicates through. + /// + private ILocalDb Db + { + get + { + if (_db is not null) return _db; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) + .Build(); + + _provider = new ServiceCollection() + .AddZbLocalDb(configuration, db => + { + using var connection = db.CreateConnection(); + AlarmSfSchema.Apply(connection); + db.RegisterReplicated(AlarmSfSchema.EventsTable); + }) + .BuildServiceProvider(); + + return _db = _provider.GetRequiredService(); + } + } + /// Cleans up test resources. public void Dispose() { - try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { } + _provider?.Dispose(); + + // Pooled connections keep a handle open past dispose; clearing the pools first is what + // makes the delete actually succeed. + SqliteConnection.ClearAllPools(); + + foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" }) + { + try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ } + } } private sealed class FakeWriter : IAlarmHistorianWriter @@ -81,7 +128,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task EnqueueThenDrain_Ack_removes_row() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); sink.GetStatus().QueueDepth.ShouldBe(1); @@ -102,7 +149,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Drain_with_empty_queue_is_noop() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.DrainOnceAsync(CancellationToken.None); @@ -116,7 +163,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable { var writer = new FakeWriter(); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); var before = sink.CurrentBackoff; @@ -133,7 +180,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable { var writer = new FakeWriter(); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); await sink.DrainOnceAsync(CancellationToken.None); @@ -153,7 +200,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable var writer = new FakeWriter(); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("bad"), CancellationToken.None); await sink.EnqueueAsync(Event("good"), CancellationToken.None); @@ -169,7 +216,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Writer_exception_treated_as_retry_for_whole_batch() { var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") }; - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); await sink.DrainOnceAsync(CancellationToken.None); @@ -189,8 +236,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Capacity_eviction_drops_oldest_nondeadlettered_row() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, batchSize: 100, capacity: 3); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, batchSize: 100, capacity: 3); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); await sink.EnqueueAsync(Event("A2"), CancellationToken.None); @@ -217,8 +264,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable var writer = new FakeWriter(); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail); - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, deadLetterRetention: TimeSpan.FromDays(30), + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, deadLetterRetention: TimeSpan.FromDays(30), clock: () => clock); await sink.EnqueueAsync(Event("bad"), CancellationToken.None); @@ -238,7 +285,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable { var writer = new FakeWriter(); writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("bad"), CancellationToken.None); await sink.DrainOnceAsync(CancellationToken.None); @@ -257,7 +304,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Backoff_ladder_caps_at_60s() { var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease }; - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); @@ -278,8 +325,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task RetryPlease_dead_letters_row_after_MaxAttempts() { var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease }; - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, maxAttempts: 3); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, maxAttempts: 3); await sink.EnqueueAsync(Event("poison"), CancellationToken.None); @@ -316,17 +363,89 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None); } + /// + /// A closed drain gate must leave the queue completely alone — no writer call, no attempt + /// bump, no purge. + /// + /// + /// This is the property that keeps alarm delivery exactly-once across a redundant pair. + /// The buffer replicates, so the Secondary holds a full copy of the Primary's queue; a + /// Secondary that drained would re-send every event, continuously. + /// + [Fact] + public async Task Drain_does_nothing_while_the_gate_is_closed() + { + var writer = new FakeWriter(); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => false); + + await sink.EnqueueAsync(Event("A1"), CancellationToken.None); + await sink.EnqueueAsync(Event("A2"), CancellationToken.None); + + await sink.DrainOnceAsync(CancellationToken.None); + + writer.Batches.Count.ShouldBe(0, "a gated-off node must not deliver"); + + var status = sink.GetStatus(); + status.QueueDepth.ShouldBe(2, "the rows stay queued for whichever node holds the Primary role"); + status.DrainState.ShouldBe( + HistorianDrainState.NotPrimary, + "a Secondary's rising queue must be distinguishable from a stalled drain"); + } + + /// + /// The positive control for the case above: the same queue, the same sink, drains as soon + /// as the gate opens. Without this, a sink that was simply broken would pass the + /// gated-off assertion. + /// + [Fact] + public async Task Drain_delivers_the_retained_queue_once_the_gate_opens() + { + var writer = new FakeWriter(); + var isPrimary = false; + // ReSharper disable once AccessToModifiedClosure — flipping it mid-test is the point. + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => isPrimary); + + await sink.EnqueueAsync(Event("A1"), CancellationToken.None); + await sink.DrainOnceAsync(CancellationToken.None); + writer.Batches.Count.ShouldBe(0); + + isPrimary = true; // this node is promoted + await sink.DrainOnceAsync(CancellationToken.None); + + writer.Batches.Count.ShouldBe(1); + writer.Batches[0].Select(e => e.AlarmId).ShouldBe(["A1"]); + sink.GetStatus().QueueDepth.ShouldBe(0); + } + + /// + /// A gate that throws must be read as "not now", never as permission. Draining on a gate + /// fault would put a pair into dual-delivery precisely when its redundancy signal is sick. + /// + [Fact] + public async Task Drain_treats_a_throwing_gate_as_closed() + { + var writer = new FakeWriter(); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, drainGate: () => throw new InvalidOperationException("role unavailable")); + + await sink.EnqueueAsync(Event("A1"), CancellationToken.None); + await sink.DrainOnceAsync(CancellationToken.None); + + writer.Batches.Count.ShouldBe(0, "a faulted gate must fail closed"); + sink.GetStatus().QueueDepth.ShouldBe(1); + } + /// Verifies that the constructor rejects invalid arguments. [Fact] public void Ctor_rejects_bad_args() { var w = new FakeWriter(); - Should.Throw(() => new SqliteStoreAndForwardSink("", w, _log)); - Should.Throw(() => new SqliteStoreAndForwardSink(_dbPath, null!, _log)); - Should.Throw(() => new SqliteStoreAndForwardSink(_dbPath, w, null!)); - Should.Throw(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0)); - Should.Throw(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0)); - Should.Throw(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, maxAttempts: 0)); + Should.Throw(() => new LocalDbStoreAndForwardSink(null!, w, _log)); + Should.Throw(() => new LocalDbStoreAndForwardSink(Db, null!, _log)); + Should.Throw(() => new LocalDbStoreAndForwardSink(Db, w, null!)); + Should.Throw(() => new LocalDbStoreAndForwardSink(Db, w, _log, batchSize: 0)); + Should.Throw(() => new LocalDbStoreAndForwardSink(Db, w, _log, capacity: 0)); + Should.Throw(() => new LocalDbStoreAndForwardSink(Db, w, _log, maxAttempts: 0)); } /// Verifies that a disposed sink rejects enqueue operations. @@ -334,7 +453,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Disposed_sink_rejects_enqueue() { var writer = new FakeWriter(); - var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); sink.Dispose(); await Should.ThrowAsync( @@ -352,11 +471,14 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Drain_with_corrupt_payload_row_deadletters_it_and_keeps_good_rows_aligned() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + var clock = new TickingClock(); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next); - // Row 1: good. Row 2: corrupt JSON (inserted directly). Row 3: good. + // Row 1: good. Row 2: corrupt JSON (inserted directly). Row 3: good. The shared ticking + // clock is what pins the corrupt row BETWEEN the good ones — which is the whole point of + // this case, as distinct from the corrupt-at-the-head case below. await sink.EnqueueAsync(Event("good-1"), CancellationToken.None); - InsertCorruptRow("corrupt"); + InsertCorruptRow("corrupt", clock.Next()); await sink.EnqueueAsync(Event("good-2"), CancellationToken.None); await sink.DrainOnceAsync(CancellationToken.None); @@ -383,9 +505,10 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Drain_with_corrupt_head_row_does_not_stall_queue() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + var clock = new TickingClock(); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next); - InsertCorruptRow("corrupt-head"); + InsertCorruptRow("corrupt-head", clock.Next()); await sink.EnqueueAsync(Event("good-1"), CancellationToken.None); await sink.DrainOnceAsync(CancellationToken.None); @@ -409,7 +532,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task StartDrainLoop_honors_backoff_and_slows_cadence_under_retry() { var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease }; - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); @@ -436,7 +559,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task StartDrainLoop_keeps_steady_cadence_when_writer_is_healthy() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); sink.StartDrainLoop(TimeSpan.FromMilliseconds(30)); @@ -465,7 +588,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable // exception... so to exercise the *callback* catch we instead make the // fault originate from the writer itself but assert the loop self-heals. var writer = new ThrowingThenHealingWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); sink.StartDrainLoop(TimeSpan.FromMilliseconds(30)); @@ -490,7 +613,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Concurrent_enqueue_and_drain_do_not_throw_sqlite_busy() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); var faults = new List(); var enqueuers = Enumerable.Range(0, 4).Select(t => Task.Run(async () => @@ -561,7 +684,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Writer_returning_wrong_cardinality_outcomes_sets_backing_off_and_keeps_rows() { var writer = new WrongCardinalityWriter(returnExtraOutcome: true); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); await sink.EnqueueAsync(Event("A2"), CancellationToken.None); @@ -591,8 +714,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Capacity_eviction_increments_evicted_count_on_status() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, batchSize: 100, capacity: 3); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, batchSize: 100, capacity: 3); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); await sink.EnqueueAsync(Event("A2"), CancellationToken.None); @@ -616,7 +739,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task GetStatus_snapshot_is_consistent_under_concurrent_drain() { var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease }; - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); // Fill the queue. for (var i = 0; i < 10; i++) @@ -690,8 +813,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task EnqueueAsync_does_not_count_all_rows_on_every_call_below_capacity() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, batchSize: 100, capacity: 10_000); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, batchSize: 100, capacity: 10_000); for (var i = 0; i < 200; i++) await sink.EnqueueAsync(Event($"E{i}"), CancellationToken.None); @@ -705,7 +828,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable /// /// Regression for Core.AlarmHistorian-008: across every queue mutation (enqueue, /// Ack drain, PermanentFail drain, capacity eviction, RetryDeadLettered) the - /// queue depth reported by must + /// queue depth reported by must /// stay aligned with a fresh COUNT(*) against the database. Catches drift /// bugs in the in-memory counter introduced by the perf optimisation. /// @@ -713,8 +836,8 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Enqueue_and_drain_keep_queue_depth_consistent_with_storage() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink( - _dbPath, writer, _log, batchSize: 5, capacity: 8); + using var sink = new LocalDbStoreAndForwardSink( + Db, writer, _log, batchSize: 5, capacity: 8); // Burst-enqueue below capacity — the in-memory counter must stay aligned with the // SELECT COUNT(*) that GetStatus runs. @@ -760,7 +883,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Counter_remains_consistent_under_concurrent_enqueue_and_drain() { var writer = new FakeWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); var enqueuers = Enumerable.Range(0, 3).Select(t => Task.Run(async () => { @@ -790,7 +913,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable /// Regression for Core.AlarmHistorian-012: when WriteBatchAsync throws /// the drain exits via re-throw /// without resetting _drainState, leaving the status surface permanently - /// showing Draining. The next call to + /// showing Draining. The next call to /// must reflect Idle or BackingOff, not the stale Draining /// state that was set at the top of the drain tick. /// @@ -798,7 +921,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable public async Task Drain_cancelled_mid_write_resets_drain_state_to_not_draining() { var writer = new CancellableWriter(); - using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); await sink.EnqueueAsync(Event("A1"), CancellationToken.None); using var cts = new CancellationTokenSource(); @@ -840,49 +963,68 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable /// COUNT(*) read directly from storage — proves the in-memory counter has not /// drifted from the persisted truth. /// - private void AssertQueueDepthMatchesStorage(SqliteStoreAndForwardSink sink) + private void AssertQueueDepthMatchesStorage(LocalDbStoreAndForwardSink sink) { - using var conn = new SqliteConnection($"Data Source={_dbPath}"); - conn.Open(); + using var conn = Db.CreateConnection(); using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0"; + cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0"; var live = (long)(cmd.ExecuteScalar() ?? 0L); sink.GetStatus().QueueDepth.ShouldBe(live, "GetStatus must agree with a fresh COUNT(*)"); } /// - /// Regression for Core.AlarmHistorian-014: - /// and must throw - /// after + /// Regression for Core.AlarmHistorian-014: + /// and must throw + /// after /// is called, consistent with the guards already present on - /// and - /// . + /// and + /// . /// [Fact] public void Disposed_sink_throws_ObjectDisposedException_on_GetStatus_and_RetryDeadLettered() { var writer = new FakeWriter(); - var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log); + var sink = new LocalDbStoreAndForwardSink(Db, writer, _log); sink.Dispose(); Should.Throw(() => sink.GetStatus()); Should.Throw(() => sink.RetryDeadLettered()); } - /// Insert a queue row whose PayloadJson cannot deserialize into an AlarmHistorianEvent. - private void InsertCorruptRow(string alarmId) + /// + /// Insert a queue row whose payload cannot deserialize into an AlarmHistorianEvent, stamped + /// at an explicit enqueue time. + /// + /// + /// The timestamp is a parameter rather than DateTime.UtcNow because the drain reads + /// in enqueued_at_utc order, and the tests that use this helper care precisely about + /// where the corrupt row sits in that order. Three unsynchronised clock reads can land on + /// one tick, which would make the interleaving — and therefore the test — a coin flip. + /// + private void InsertCorruptRow(string alarmId, DateTime enqueuedAt) { - using var conn = new SqliteConnection($"Data Source={_dbPath}"); - conn.Open(); + using var conn = Db.CreateConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ - INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount) - VALUES ($alarmId, $enqueued, $payload, 0); + INSERT INTO alarm_sf_events (id, alarm_id, enqueued_at_utc, payload_json, attempt_count) + VALUES ($id, $alarmId, $enqueued, $payload, 0); """; + cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N")); cmd.Parameters.AddWithValue("$alarmId", alarmId); - cmd.Parameters.AddWithValue("$enqueued", DateTime.UtcNow.ToString("O")); + cmd.Parameters.AddWithValue("$enqueued", enqueuedAt.ToString("O")); // JSON literal "null" round-trips through Deserialize as a null reference. cmd.Parameters.AddWithValue("$payload", "null"); cmd.ExecuteNonQuery(); } + + /// + /// Monotonic test clock — every read advances one second, so enqueue order is total and + /// the drain's ORDER BY enqueued_at_utc is deterministic. + /// + private sealed class TickingClock + { + private DateTime _now = new(2026, 7, 21, 0, 0, 0, DateTimeKind.Utc); + + public DateTime Next() => _now = _now.AddSeconds(1); + } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj index 0cb9db4c..3f567086 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj @@ -17,6 +17,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs index ddbfc80a..868ab7f0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs @@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// /// Alarm-write cutover seam test (T13). The Host's AddAlarmHistorian wiring drains the durable -/// SqliteStoreAndForwardSink through this factory, so it must yield the gateway-backed writer — +/// LocalDbStoreAndForwardSink through this factory, so it must yield the gateway-backed writer — /// sourcing the single gateway's connection from (endpoint/key/TLS), /// not the legacy Wonderware-shaped AlarmHistorian host/port. Built offline: the underlying /// channel dials lazily, so both the factory and the writer ctor perform no network I/O (a bogus, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs index 1a86a92e..55719278 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs @@ -22,6 +22,61 @@ public sealed class HistorianGatewayClientAdapterTests Assert.NotNull(adapter); } + /// + /// A plaintext h2c gateway (an http:// endpoint with UseTls=false) must + /// construct, using nothing but documented defaults. + /// + /// + /// + /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig. + /// docs/Historian.md and CLAUDE.md both document http:// as a supported + /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact + /// unreachable: this factory forwarded the TLS-only options unconditionally, and + /// defaults to + /// , so it always sent RequireCertificateValidation=true — + /// which the client rejects outright when UseTls=false + /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true"). + /// + /// + /// So every h2c deployment crashed at startup, and the only way to avoid it was to + /// set AllowUntrustedServerCertificate=true — asserting a certificate posture for a + /// connection that has no certificate at all. + /// + /// + [Fact] + public void Adapter_constructs_for_plaintext_h2c_endpoint() + { + var opts = new ServerHistorianOptions + { + Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_x_y", UseTls = false, + }; + + using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance); + + adapter.ShouldNotBeNull(); + } + + /// + /// A pinned CA path is likewise TLS-only, and must not leak into an h2c client — the same + /// rejection, reached by a different field. + /// + [Fact] + public void Adapter_ignores_tls_only_settings_on_a_plaintext_endpoint() + { + var opts = new ServerHistorianOptions + { + Enabled = true, + Endpoint = "http://localhost:5222", + ApiKey = "histgw_x_y", + UseTls = false, + CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem", + }; + + using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance); + + adapter.ShouldNotBeNull(); + } + /// /// archreview 06/S-11 — an enabled historian with an empty Endpoint must fail with a /// named, config-key-carrying (defense-in-depth for any diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfConvergenceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfConvergenceTests.cs new file mode 100644 index 00000000..2ebf485a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfConvergenceTests.cs @@ -0,0 +1,197 @@ +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb; + +/// +/// LocalDb Phase 2 — two driver nodes replicating the alarm store-and-forward buffer over a real +/// loopback h2c transport, through the real fail-closed interceptor. +/// +/// +/// +/// This is the test the phase exists to pass. Everything upstream — the schema, the +/// registration, the sink rewire, the drain gate — can be individually green while a +/// redundant pair still fails to share the undelivered alarm history that is the whole +/// point of moving the queue here. +/// +/// +/// Rows are written through the production rather +/// than by hand-rolled INSERTs. The id derivation, the column set and the delete-on-ack +/// semantics under test are then the ones production actually uses. +/// +/// +[Collection("LocalDbPairConvergence")] +public sealed class AlarmSfConvergenceTests +{ + private static readonly Serilog.ILogger Log = new LoggerConfiguration().CreateLogger(); + + /// A writer that acknowledges everything — the drain's happy path. + private sealed class AckWriter : IAlarmHistorianWriter + { + public Task> WriteBatchAsync( + IReadOnlyList batch, CancellationToken cancellationToken) => + Task.FromResult>( + batch.Select(_ => HistorianWriteOutcome.Ack).ToList()); + } + + /// A writer that never acknowledges — a historian outage, which is when the buffer matters. + private sealed class OutageWriter : IAlarmHistorianWriter + { + public Task> WriteBatchAsync( + IReadOnlyList batch, CancellationToken cancellationToken) => + Task.FromResult>( + batch.Select(_ => HistorianWriteOutcome.RetryPlease).ToList()); + } + + private static AlarmHistorianEvent Event(string alarmId, int second) => new( + AlarmId: alarmId, + EquipmentPath: "line-1/eq-1", + AlarmName: "temp-high", + AlarmTypeName: "LimitAlarm", + Severity: AlarmSeverity.High, + EventKind: "Activated", + Message: "temperature high", + User: "system", + Comment: null, + TimestampUtc: new DateTime(2026, 7, 21, 0, 0, second, DateTimeKind.Utc)); + + /// The set of row ids on a node, ordered — equal sets on both nodes means converged. + private static async Task IdsAsync(ILocalDb db) + { + var rows = await db.QueryAsync( + "SELECT id FROM alarm_sf_events ORDER BY id", + static r => r.GetString(0)); + return string.Join(",", rows); + } + + private static async Task CountAsync(ILocalDb db) + { + var rows = await db.QueryAsync( + "SELECT COUNT(*) FROM alarm_sf_events", static r => r.GetInt64(0)); + return rows[0]; + } + + [Fact] + public async Task AlarmBurstOnA_ConvergesToB_AndOplogDrains() + { + await using var pair = new LocalDbPairHarness(); + await pair.StartAsync(); + + // The Primary's sink. Its drain is left unstarted so the burst stays queued and observable; + // an outage is exactly the state in which this buffer has to survive a node loss. + using var sink = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log); + for (var i = 0; i < 25; i++) + await sink.EnqueueAsync(Event($"eq/alarm-{i}", i), TestContext.Current.CancellationToken); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.B) == 25, + "node B to hold all 25 buffered alarm events"); + + (await IdsAsync(pair.B)).ShouldBe(await IdsAsync(pair.A)); + + // Oplog drains to empty once B acks: the steady state of a converged pair, and the proof + // the rows were genuinely acknowledged rather than merely sent. + await LocalDbPairHarness.WaitUntilAsync( + async () => await LocalDbPairHarness.OplogDepthAsync(pair.A) == 0, + "node A's oplog to drain after node B acknowledges the burst"); + } + + [Fact] + public async Task DeliveredRowsAreRemovedFromBothNodes() + { + // The no-redeliver-after-failover property, asserted at the data layer. An acknowledged row + // is DELETEd, and the delete has to reach the peer as a tombstone — otherwise a promoted + // standby would find the Primary's already-delivered events still queued and send them again. + await using var pair = new LocalDbPairHarness(); + await pair.StartAsync(); + + using var buffering = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log); + for (var i = 0; i < 5; i++) + await buffering.EnqueueAsync(Event($"eq/alarm-{i}", i), TestContext.Current.CancellationToken); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.B) == 5, + "node B to receive the buffered events"); + + // The historian comes back and the Primary drains. + using var draining = new LocalDbStoreAndForwardSink(pair.A, new AckWriter(), Log); + await draining.DrainOnceAsync(TestContext.Current.CancellationToken); + (await CountAsync(pair.A)).ShouldBe(0); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.B) == 0, + "node B to drop its copies once node A delivered them"); + + (await LocalDbPairHarness.TombstoneCountAsync(pair.B, "alarm_sf_events")) + .ShouldBe(5, "the deletes must arrive as tombstones, not vanish silently"); + } + + [Fact] + public async Task WritesWhileTransportDown_SurviveRejoin() + { + // The realistic outage shape: alarms keep arriving on the Primary while the pair link is + // down. Nothing may be lost when it comes back. + await using var pair = new LocalDbPairHarness(); + await pair.StartAsync(); + + using var sink = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log); + await sink.EnqueueAsync(Event("eq/before", 1), TestContext.Current.CancellationToken); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.B) == 1, + "the pre-outage event to reach node B"); + + await pair.StopPassiveAsync(); + + for (var i = 0; i < 10; i++) + await sink.EnqueueAsync(Event($"eq/during-{i}", 10 + i), TestContext.Current.CancellationToken); + + await pair.RestartPairAsync(); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.B) == 11, + "every event buffered during the outage to reach node B after the rejoin"); + + (await IdsAsync(pair.B)).ShouldBe(await IdsAsync(pair.A)); + } + + [Fact] + public async Task TheSameEventOnBothNodes_ConvergesToOneRow() + { + // Both adapters default-write while their redundancy role is unknown, so in the boot window + // the pair genuinely double-accepts the same fanned transition. Payload-derived ids are what + // turn that into one row instead of two; random GUIDs would leave the duplicate in the + // buffer for the eventual Primary to deliver twice. + await using var pair = new LocalDbPairHarness(); + await pair.StartAsync(); + + var shared = Event("eq/shared", 1); + using var sinkA = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log); + using var sinkB = new LocalDbStoreAndForwardSink(pair.B, new OutageWriter(), Log); + + await sinkA.EnqueueAsync(shared, TestContext.Current.CancellationToken); + await sinkB.EnqueueAsync(shared, TestContext.Current.CancellationToken); + + // A second, DISTINCT event on B. Without it the scenario would pass with replication + // switched off entirely — each node would hold its own single copy and "count == 1 on + // both" would be satisfied by two unconverged databases. + await sinkB.EnqueueAsync(Event("eq/only-on-b", 2), TestContext.Current.CancellationToken); + + await LocalDbPairHarness.WaitUntilAsync( + async () => await CountAsync(pair.A) == 2 && await CountAsync(pair.B) == 2, + "both nodes to hold two rows: the doubly-accepted event collapsed, plus B's own"); + + (await IdsAsync(pair.A)).ShouldBe(await IdsAsync(pair.B)); + + // Held, not merely reached: a late-arriving replica of the peer's copy of the shared event + // must not add a third row a moment later. + (await LocalDbPairHarness.HeldWithinAsync( + async () => await CountAsync(pair.A) > 2 || await CountAsync(pair.B) > 2, + TimeSpan.FromSeconds(3))) + .ShouldBeFalse("the duplicate must stay collapsed"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs new file mode 100644 index 00000000..0a581633 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfLegacyMigratorTests.cs @@ -0,0 +1,333 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb; + +/// +/// The one-time copy of the pre-consolidation alarm-historian.db queue into the +/// consolidated LocalDb. +/// +/// +/// +/// What is being protected is undelivered alarm history: rows that are in the legacy file +/// precisely because the historian could not be reached. Dropping them on upgrade would +/// discard exactly the audit trail the store-and-forward queue exists to preserve. +/// +/// +/// Run against a real temp-file built through the production +/// , so the registration order the migrator depends on is +/// the one the host actually runs — a hand-written schema here would prove only that the +/// test agrees with itself. +/// +/// +public sealed class AlarmSfLegacyMigratorTests : IDisposable +{ + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-mig-{Guid.NewGuid():N}.db"); + + private readonly string _legacyPath = + Path.Combine(Path.GetTempPath(), $"otopcua-alarmsf-legacy-{Guid.NewGuid():N}.db"); + + private ServiceProvider? _provider; + + // ---- the cases --------------------------------------------------------------------------- + + [Fact] + public async Task Every_legacy_row_lands_in_the_consolidated_table() + { + SeedLegacy( + (1, "eq/a", Payload("eq/a"), 0, dead: 0), + (2, "eq/b", Payload("eq/b"), 3, dead: 0), + (3, "eq/c", Payload("eq/c"), 9, dead: 1)); + + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + (await CountAsync(db)).ShouldBe(3); + // Attempt counts and the dead-letter flag must survive: a migrated row that lost its + // attempt count would restart its retry budget, and one that lost its dead-letter flag + // would be re-sent to a historian that already refused it permanently. + var rows = await db.QueryAsync( + "SELECT alarm_id, attempt_count, dead_lettered FROM alarm_sf_events ORDER BY alarm_id", + r => (AlarmId: r.GetString(0), Attempts: r.GetInt64(1), Dead: r.GetInt64(2)), + parameters: null, + TestContext.Current.CancellationToken); + + rows.ShouldBe([("eq/a", 0L, 0L), ("eq/b", 3L, 0L), ("eq/c", 9L, 1L)]); + } + + [Fact] + public async Task Migrated_rows_enter_the_oplog() + { + // THE ordering pin. Capture is trigger-based, so rows copied in before + // RegisterReplicated would never enter the oplog and would never reach the peer — + // silently, and permanently. A migrator invoked too early passes every other case here. + SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0)); + + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + var oplog = await db.QueryAsync( + "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'", + r => r.GetInt64(0), + parameters: null, + TestContext.Current.CancellationToken); + + oplog[0].ShouldBe(1); + } + + [Fact] + public async Task A_second_run_is_a_no_op() + { + SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0)); + var db = BuildDb(); + var config = ConfigWithLegacyPath(); + + AlarmSfLegacyMigrator.Migrate(db, config); + AlarmSfLegacyMigrator.Migrate(db, config); + + (await CountAsync(db)).ShouldBe(1); + File.Exists(_legacyPath).ShouldBeFalse("the legacy file is renamed once the copy commits"); + File.Exists(_legacyPath + ".migrated").ShouldBeTrue(); + } + + [Fact] + public async Task Re_migrating_the_same_rows_cannot_duplicate_them() + { + // The sidecar rename is the normal guard, but it is not the only one that has to hold: + // a crash between commit and rename leaves the file in place, and the next boot copies it + // again. Ids are derived from the payload, so the re-copy collides with itself and is + // ignored rather than doubling the queue. + SeedLegacy((1, "eq/a", Payload("eq/a"), 0, dead: 0)); + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + File.Move(_legacyPath + ".migrated", _legacyPath); // simulate the crash-before-rename + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + (await CountAsync(db)).ShouldBe(1); + } + + [Fact] + public async Task An_older_legacy_file_missing_a_column_still_migrates() + { + // A file written by an older build predates LastError. Naming a missing column in the + // SELECT throws "no such column", which would discard every row in the table rather than + // the one field. Intersecting the column list first means the data migrates and the + // absent column keeps its schema default. + SeedLegacyWithoutLastError((1, "eq/a", Payload("eq/a"))); + + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + (await CountAsync(db)).ShouldBe(1); + } + + [Fact] + public async Task A_failed_copy_leaves_the_legacy_file_untouched() + { + // The legacy table exists but its payload column is missing, so the required-column guard + // rejects the file. Nothing is copied and nothing is renamed: the operator still has the + // original to recover from, which is the whole point of renaming only after commit. + SeedUnrecognisedLegacy(); + + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + (await CountAsync(db)).ShouldBe(0); + File.Exists(_legacyPath).ShouldBeTrue("an un-copied file must not be renamed away"); + File.Exists(_legacyPath + ".migrated").ShouldBeFalse(); + } + + [Fact] + public async Task A_missing_legacy_file_is_a_clean_no_op() + { + // The expected case on a fresh install and on any node that never enabled the historian. + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + (await CountAsync(db)).ShouldBe(0); + } + + [Theory] + [InlineData(":memory:")] + [InlineData("file:thing?mode=memory")] + [InlineData("")] + public async Task Non_file_legacy_sources_are_no_ops(string configured) + { + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, Config(configured)); + + (await CountAsync(db)).ShouldBe(0); + } + + [Fact] + public async Task Two_nodes_migrating_the_same_event_converge_on_one_row() + { + // A warm pair's two legacy files overlap: HistorianAdapterActor default-writes while the + // redundancy role is unknown, so both nodes accepted the same transitions during every + // boot window. Node-prefixed ids would carry that duplication forward into the merged + // buffer permanently; payload-derived ids collapse it. + var shared = Payload("eq/shared"); + SeedLegacy((1, "eq/shared", shared, 0, dead: 0)); + + var db = BuildDb(); + AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath()); + + // The peer's file: a different legacy RowId for the very same event. + var peerLegacy = _legacyPath + ".peer"; + SeedLegacyAt(peerLegacy, (77, "eq/shared", shared, 0, dead: 0)); + AlarmSfLegacyMigrator.Migrate(db, Config(peerLegacy)); + + (await CountAsync(db)).ShouldBe(1); + + try { File.Delete(peerLegacy + ".migrated"); } catch { /* best effort */ } + } + + // ---- fixture ----------------------------------------------------------------------------- + + private ILocalDb BuildDb() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = _dbPath }) + .Build(); + + _provider = new ServiceCollection() + .AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)) + .BuildServiceProvider(); + + return _provider.GetRequiredService(); + } + + private IConfiguration ConfigWithLegacyPath() => Config(_legacyPath); + + private static IConfiguration Config(string legacyPath) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AlarmHistorian:DatabasePath"] = legacyPath, + }) + .Build(); + + private static async Task CountAsync(ILocalDb db) + { + var rows = await db.QueryAsync( + "SELECT COUNT(*) FROM alarm_sf_events", + r => r.GetInt64(0), + parameters: null, + TestContext.Current.CancellationToken); + return rows[0]; + } + + /// A payload shaped like a serialized AlarmHistorianEvent. + private static string Payload(string alarmId) => + $$"""{"AlarmId":"{{alarmId}}","EquipmentPath":"line/eq","AlarmName":"hi","TimestampUtc":"2026-07-20T00:00:00Z"}"""; + + private void SeedLegacy(params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows) => + SeedLegacyAt(_legacyPath, rows); + + /// Creates a legacy queue file with the full pre-cutover schema and the given rows. + private static void SeedLegacyAt( + string path, params (long RowId, string AlarmId, string Payload, long Attempts, long dead)[] rows) + { + using var conn = Open(path); + Exec(conn, """ + CREATE TABLE Queue ( + RowId INTEGER PRIMARY KEY AUTOINCREMENT, + AlarmId TEXT NOT NULL, + EnqueuedUtc TEXT NOT NULL, + PayloadJson TEXT NOT NULL, + AttemptCount INTEGER NOT NULL DEFAULT 0, + LastAttemptUtc TEXT NULL, + LastError TEXT NULL, + DeadLettered INTEGER NOT NULL DEFAULT 0 + ); + """); + + foreach (var row in rows) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson, AttemptCount, DeadLettered) + VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload, $attempts, $dead) + """; + cmd.Parameters.AddWithValue("$id", row.RowId); + cmd.Parameters.AddWithValue("$alarm", row.AlarmId); + cmd.Parameters.AddWithValue("$payload", row.Payload); + cmd.Parameters.AddWithValue("$attempts", row.Attempts); + cmd.Parameters.AddWithValue("$dead", row.dead); + cmd.ExecuteNonQuery(); + } + } + + /// A legacy file from an older build, predating the LastError column. + private void SeedLegacyWithoutLastError(params (long RowId, string AlarmId, string Payload)[] rows) + { + using var conn = Open(_legacyPath); + Exec(conn, """ + CREATE TABLE Queue ( + RowId INTEGER PRIMARY KEY AUTOINCREMENT, + AlarmId TEXT NOT NULL, + EnqueuedUtc TEXT NOT NULL, + PayloadJson TEXT NOT NULL, + AttemptCount INTEGER NOT NULL DEFAULT 0, + DeadLettered INTEGER NOT NULL DEFAULT 0 + ); + """); + + foreach (var row in rows) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO Queue (RowId, AlarmId, EnqueuedUtc, PayloadJson) + VALUES ($id, $alarm, '2026-07-20T00:00:00.0000000Z', $payload) + """; + cmd.Parameters.AddWithValue("$id", row.RowId); + cmd.Parameters.AddWithValue("$alarm", row.AlarmId); + cmd.Parameters.AddWithValue("$payload", row.Payload); + cmd.ExecuteNonQuery(); + } + } + + /// A file with a Queue table this build cannot read — no payload column. + private void SeedUnrecognisedLegacy() + { + using var conn = Open(_legacyPath); + Exec(conn, "CREATE TABLE Queue (RowId INTEGER PRIMARY KEY AUTOINCREMENT, Something TEXT);"); + Exec(conn, "INSERT INTO Queue (Something) VALUES ('x');"); + } + + private static SqliteConnection Open(string path) + { + var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString()); + conn.Open(); + return conn; + } + + private static void Exec(SqliteConnection conn, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + public void Dispose() + { + _provider?.Dispose(); + SqliteConnection.ClearAllPools(); + + foreach (var baseName in new[] { _dbPath, _legacyPath, _legacyPath + ".migrated" }) + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + try { File.Delete(baseName + suffix); } catch { /* best effort */ } + } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs index a221dee1..abfd7e75 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs @@ -265,7 +265,7 @@ public sealed class LocalDbPairHarness : IAsyncDisposable .Build(); return new ServiceCollection() - .AddZbLocalDb(config, LocalDbSetup.OnReady) + .AddZbLocalDb(config, db => LocalDbSetup.OnReady(db, config)) .BuildServiceProvider(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs index 2db014c2..3e45a17d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs @@ -39,14 +39,14 @@ public sealed class LocalDbSetupTests : IDisposable .Build(); _provider = new ServiceCollection() - .AddZbLocalDb(configuration, LocalDbSetup.OnReady) + .AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration)) .BuildServiceProvider(); return _provider.GetRequiredService(); } [Fact] - public void OnReady_RegistersExactlyTheTwoDeploymentTables() + public void OnReady_RegistersExactlyTheThreeReplicatedTables() { // Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call // (that table then never replicates). "No more" catches an accidental registration — @@ -54,7 +54,7 @@ public sealed class LocalDbSetupTests : IDisposable var db = BuildDb(); db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) - .ShouldBe(["deployment_artifacts", "deployment_pointer"]); + .ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]); } [Fact] @@ -78,6 +78,50 @@ public sealed class LocalDbSetupTests : IDisposable db.ReplicatedTables["deployment_pointer"].PkColumns.ShouldBe(["cluster_id"]); } + [Fact] + public void AlarmSfEvents_PkIsTheAppMintedId() + { + // A single TEXT PK the application mints. The legacy queue this table replaces keyed on + // `RowId INTEGER PRIMARY KEY AUTOINCREMENT`, which LWW cannot replicate: two nodes would + // independently allocate rowid 7 for different alarms and silently overwrite each other. + var db = BuildDb(); + + db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]); + } + + [Fact] + public async Task AlarmSfEventRows_EnterTheOplog() + { + // Same ordering assertion as the deployment-pointer test below, for the table Phase 2 adds. + // Worth its own case because the alarm table is registered by a different call site, and a + // registration added ahead of its DDL would fail loudly while one added after the migrator + // would fail silently. + var db = BuildDb(); + + await db.ExecuteAsync( + """ + INSERT INTO alarm_sf_events + (id, alarm_id, enqueued_at_utc, payload_json, attempt_count) + VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0) + """, + new + { + Id = new string('c', 64), + AlarmId = "equip/alarm-1", + EnqueuedAtUtc = "2026-07-21T00:00:00.0000000Z", + PayloadJson = """{"AlarmId":"equip/alarm-1"}""", + }, + TestContext.Current.CancellationToken); + + var oplogRows = await db.QueryAsync( + "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'", + r => r.GetInt32(0), + parameters: null, + TestContext.Current.CancellationToken); + + oplogRows[0].ShouldBe(1); + } + [Fact] public async Task RowsWrittenAfterOnReady_EnterTheOplog() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs index 77060548..82f6c36e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs @@ -84,7 +84,7 @@ public sealed class LocalDbWiringTests : IDisposable } [Fact] - public void DriverGraph_LocalDbResolvesAsASingletonWithBothCacheTablesRegistered() + public void DriverGraph_LocalDbResolvesAsASingletonWithEveryReplicatedTableRegistered() { var sp = BuildDriverGraph(); @@ -95,7 +95,7 @@ public sealed class LocalDbWiringTests : IDisposable // Exact set, both directions — a dropped registration replicates nothing; an extra one costs // three triggers and oplog volume on every write. first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) - .ShouldBe(["deployment_artifacts", "deployment_pointer"]); + .ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]); } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs index d374c6b9..54baf356 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -62,11 +62,18 @@ public sealed class ServerHistorianOptionsValidatorTests result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint")); } - /// Enabled with a valid absolute https endpoint succeeds. + /// + /// Enabled with a valid absolute https endpoint and a key succeeds. The key is not + /// incidental: this case previously omitted it and still asserted success, which pinned a config + /// that actually crash-loops the host — see . + /// [Fact] public void Enabled_with_valid_https_endpoint_succeeds() { - var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" }); + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", + }); result.Succeeded.ShouldBeTrue(); } @@ -96,16 +103,145 @@ public sealed class ServerHistorianOptionsValidatorTests f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled")); } - /// Alarm-only mode with a valid endpoint succeeds. + /// Alarm-only mode with a valid endpoint and a key succeeds. [Fact] public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds() { var result = Sut(alarmHistorianEnabled: true) - .Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222" }); + .Validate(null, new ServerHistorianOptions + { + Enabled = false, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", + }); result.Succeeded.ShouldBeTrue(); } + /// + /// An empty ApiKey on a consumed section fails, because it crashes — it does not + /// degrade. + /// + /// + /// + /// Found by the LocalDb Phase 2 live gate. The rig booted with + /// AlarmHistorian:Enabled=true and a valid endpoint but no key, and every driver + /// node crash-looped on + /// ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey') + /// thrown by HistorianGatewayClientOptions.Validate() — reached through + /// GatewayHistorian.CreateAlarmWriter during Akka startup. + /// + /// + /// That is the *same* factory path, and the same crash-loop-under-a-restart-policy + /// failure mode, this validator was built to convert into a named + /// OptionsValidationException. The previous "empty ApiKey degrades, the gateway + /// just rejects calls" premise was simply wrong: the client validates its own options + /// at construction, so the process never reaches the point of making a call. + /// + /// + [Fact] + public void Consumed_with_empty_api_key_fails() + { + var result = Sut(alarmHistorianEnabled: true) + .Validate(null, new ServerHistorianOptions + { + Enabled = false, Endpoint = "https://host:5222", ApiKey = "", + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => + f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled")); + } + + /// The read path has the identical crash, so it is gated identically. + [Fact] + public void Enabled_with_empty_api_key_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "https://host:5222", ApiKey = "", + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey")); + } + + /// + /// The key must never be echoed. An endpoint is not a secret and is quoted to make the error + /// actionable; a key is, so the failure names only the setting. + /// + [Fact] + public void Api_key_failure_never_echoes_the_key() + { + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "https://host:5222", ApiKey = " ", + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal)); + } + + /// A section nobody consumes may be keyless — no failure. + [Fact] + public void Disabled_with_empty_api_key_succeeds() + { + var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" }); + + result.Succeeded.ShouldBeTrue(); + } + + /// + /// UseTls=true against an http:// endpoint fails — the third member of the + /// crash-at-construction family, and the easiest of the three for an operator to trip. + /// + /// + /// Also found by the Phase 2 live gate, immediately after the ApiKey fix: the rig points + /// at a deliberately unresolvable http:// endpoint, and UseTls defaults to + /// , so every node crash-looped a second time on + /// ArgumentException: UseTls requires an https gateway endpoint. Switching an endpoint + /// from https to http without clearing UseTls is an ordinary migration slip, and it takes + /// the host down rather than degrading it. + /// + [Fact] + public void Consumed_with_use_tls_against_http_endpoint_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true, + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => + f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222")); + } + + /// An http endpoint with TLS explicitly off is the valid h2c shape — no failure. + [Fact] + public void Consumed_with_http_endpoint_and_tls_off_succeeds() + { + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false, + }); + + result.Succeeded.ShouldBeTrue(); + } + + /// + /// The mirror slip — UseTls=false against an https:// endpoint — also fails, so the + /// scheme and the flag are pinned to agree in both directions rather than in one. + /// + [Fact] + public void Consumed_with_tls_off_against_https_endpoint_fails() + { + var result = Sut().Validate(null, new ServerHistorianOptions + { + Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false, + }); + + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls")); + } + /// /// Wiring guard (the "register-AND-consume" trap): resolving IOptions<ServerHistorianOptions>.Value /// after throws diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs new file mode 100644 index 00000000..c46952fa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs @@ -0,0 +1,211 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The bridge that carries the Primary-gate decision out of the actor and into +/// , where the alarm store-and-forward drain can read it. +/// +/// +/// +/// The drain runs on a timer owned by the sink, not on a mailbox, so it cannot receive +/// itself — but it must be Primary-scoped, because +/// the queue it drains replicates to the pair peer and two draining nodes would deliver +/// every alarm event twice. +/// +/// +/// These cases deliberately DIVERGE from in the +/// unknown-role case: the write gate denies when a peer may exist, this one allows. A duplicate +/// history row is cheap and already contemplated by the at-least-once contract; a silently +/// un-drained queue is not. See AlarmHistoryDrainGatePolicyTests. +/// +/// +public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("driver-roleview-test"); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + /// + /// Before any snapshot arrives the view must read open, not closed. + /// + /// + /// A deployment that runs no redundancy never publishes here at all. Defaulting closed + /// would silently stop its alarm history forever — the exact failure the drain gate exists + /// to avoid causing. + /// + [Fact] + public void An_unpublished_view_reads_as_primary() + { + new RedundancyRoleView().ShouldDrainAlarmHistory.ShouldBeTrue(); + } + + /// A Secondary closes the view — but only because the snapshot also names a Primary. + [Fact] + public void Secondary_role_closes_the_view_when_a_primary_exists() + { + var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2); + + AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout); + } + + /// + /// A Secondary whose Primary is some OTHER pair's node keeps draining: that node holds none of + /// this node's rows, so deferring to it would deliver them never. + /// + [Fact] + public void Secondary_role_keeps_the_view_open_when_the_primary_is_not_its_peer() + { + var view = new RecordingRoleView(); + SpawnHost(view, driverMemberCount: 4).Tell(new RedundancyStateChanged( + [ + new NodeRedundancyState(TestNode, RedundancyRole.Secondary, + IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + // A Primary DOES exist — in another pair. It is not this node's replication peer. + new NodeRedundancyState(NodeId.Parse("some-other-pair:4053"), RedundancyRole.Primary, + IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), + ], + CorrelationId.NewId())); + + AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); + } + + [Fact] + public void Primary_role_opens_the_view() + { + var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2); + + // Published, not current — `true` is the seed. See the note in the unknown-role theory. + AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); + } + + /// + /// Unknown role ⇒ the view stays open, even with driver peers present — the deliberate + /// divergence from the write gate's default-DENY. + /// + /// + /// + /// This case previously asserted the opposite, on the stated goal of mirroring the write + /// gate. The Phase 2 live gate showed that goal to be wrong for this consumer: the rig runs + /// four driver nodes in one cluster with no Redundancy section, so every role was + /// unknown and the driver count was 4 — and every node logged "Historian drain suspended", + /// including the two unpaired site-b nodes with no peer and no replication. Alarm history + /// drained nowhere, where before Phase 2 it drained fine. + /// + /// + /// See AlarmHistoryDrainGatePolicyTests for why the asymmetry of the two error costs + /// makes fail-open correct here and fail-closed correct for device writes. + /// + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + public void Unknown_role_leaves_the_view_open_whatever_the_peer_count(int driverMemberCount) + { + // A snapshot that never names this node is how the role stays unknown in practice — the + // documented identity-mismatch shape, not a contrived case. + var view = SpawnAndTellForeignSnapshot(driverMemberCount); + + // Assert on what was PUBLISHED, never on the current value. `true` is also the seeded + // default, so `AwaitAssert(() => view.ShouldDrainAlarmHistory.ShouldBeTrue())` passes at the + // first poll — before the actor has processed the snapshot — and therefore passes just as + // happily when the publish is wrong or absent. Verified: with the old write-gate verdict + // restored, the current-value form stayed green and only this form goes red. + AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); + } + + /// A demotion must move the view, not just an initial promotion. + [Fact] + public void A_demotion_closes_a_previously_open_view() + { + var view = new RecordingRoleView(); + var host = SpawnHost(view, driverMemberCount: 2); + + host.Tell(Snapshot(TestNode, RedundancyRole.Primary)); + AwaitAssert(() => view.Published.ShouldBe([true]), duration: Timeout); + + host.Tell(Snapshot(TestNode, RedundancyRole.Secondary)); + AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout); + } + + // ---------------- helpers ---------------- + + /// + /// A view that remembers every published decision, so a test can assert on the actor's + /// action rather than on a state the seed already satisfies. + /// + private sealed class RecordingRoleView : IRedundancyRoleView + { + private readonly List _published = []; + private readonly Lock _gate = new(); + private readonly RedundancyRoleView _inner = new(); + + /// Every value published so far, in order. + public IReadOnlyList Published + { + get { lock (_gate) return _published.ToArray(); } + } + + public bool ShouldDrainAlarmHistory => _inner.ShouldDrainAlarmHistory; + + public void Publish(bool shouldDrainAlarmHistory) + { + lock (_gate) _published.Add(shouldDrainAlarmHistory); + _inner.Publish(shouldDrainAlarmHistory); + } + } + + private RecordingRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount) + { + var view = new RecordingRoleView(); + SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role)); + return view; + } + + private RecordingRoleView SpawnAndTellForeignSnapshot(int driverMemberCount) + { + var view = new RecordingRoleView(); + SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary)); + return view; + } + + /// The peer this node replicates with — the only node it may ever stand down for. + private const string PeerHost = "the-peer"; + + private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) => + Sys.ActorOf(DriverHostActor.Props( + NewInMemoryDbFactory(), + TestNode, + coordinator: null, + driverMemberCountProvider: () => driverMemberCount, + redundancyRoleView: view, + replicationPeerHost: PeerHost)); + + /// + /// A snapshot of a real pair: this node in , plus a peer holding the + /// other half. The peer matters — a Secondary only stands down when a Primary exists, so a + /// single-entry snapshot would silently change what these cases test. + /// + private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) => + new( + [ + new NodeRedundancyState(node, role, + IsClusterLeader: role == RedundancyRole.Primary, + IsRoleLeaderForDriver: role == RedundancyRole.Primary, + AsOfUtc: DateTime.UtcNow), + new NodeRedundancyState( + NodeId.Parse($"{PeerHost}:4053"), + role == RedundancyRole.Primary ? RedundancyRole.Secondary : RedundancyRole.Primary, + IsClusterLeader: role != RedundancyRole.Primary, + IsRoleLeaderForDriver: role != RedundancyRole.Primary, + AsOfUtc: DateTime.UtcNow), + ], + CorrelationId.NewId()); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs index 07dac1d6..7a1262a9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/PrimaryGatePolicyTests.cs @@ -36,3 +36,86 @@ public sealed class PrimaryGatePolicyTests public void Unknown_role_resolves_by_membership(int members, bool expected) => PrimaryGatePolicy.ShouldServiceAsPrimary(null, members).ShouldBe(expected); } + +/// +/// The alarm-history drain gate, which deliberately does not mirror the device-write gate. +/// +/// +/// +/// Why a second policy is correct here. The write gate protects a shared field device: +/// two nodes driving one PLC is dangerous and irreversible, so an unknown role with a peer +/// present must deny. The drain writes to an append-only historian instead, and Phase 2's +/// delivery contract is already at-least-once across a failover, by design — identical +/// events even collapse to one row, because ids are a hash of the payload. +/// +/// +/// So the two error costs are wildly asymmetric: a false allow costs a duplicate history row, +/// while a false deny silently stops the alarm audit trail and eventually evicts it at the +/// capacity wall. This gate therefore keys on the role alone and opens when the role is +/// unknown. +/// +/// +/// Found by the LocalDb Phase 2 live gate. The rig runs four driver nodes in one Akka +/// cluster with no Redundancy section, so every node's role was unknown and the +/// cluster-wide driver count was 4 — and every node, including the two unpaired site-b nodes +/// that have no peer and no replication at all, logged "Historian drain suspended". Nothing +/// drained anywhere. Before Phase 2 that deployment drained fine, because the drain was ungated. +/// +/// +public sealed class AlarmHistoryDrainGatePolicyTests +{ + [Theory] + // A Primary always drains its own queue. + [InlineData(RedundancyRole.Primary, true, true)] + [InlineData(RedundancyRole.Primary, false, true)] + // A Secondary stands down ONLY when a Primary actually exists to stand up. + [InlineData(RedundancyRole.Secondary, true, false)] + [InlineData(RedundancyRole.Detached, true, false)] + [InlineData(RedundancyRole.Secondary, false, true)] + [InlineData(RedundancyRole.Detached, false, true)] + public void A_known_role_stands_down_only_for_a_primary_that_exists( + RedundancyRole role, bool queueSharingPeerIsPrimary, bool expected) + => PrimaryGatePolicy.ShouldDrainAlarmHistory(role, queueSharingPeerIsPrimary).ShouldBe(expected); + + /// + /// Unknown role ⇒ drain, whoever else is out there. The first case the live gate broke on, and + /// the one that separates this policy from . + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void An_unknown_role_drains(bool queueSharingPeerIsPrimary) + => PrimaryGatePolicy.ShouldDrainAlarmHistory(null, queueSharingPeerIsPrimary).ShouldBeTrue(); + + /// + /// A Secondary whose Primary is NOT its queue-sharing peer keeps draining — the deepest of the + /// live gate's findings. + /// + /// + /// RedundancyStateActor derives roles from Akka's cluster-wide RoleLeader("driver"), + /// but the alarm queue is pair-local. On the docker-dev rig the elected Primary is a CENTRAL node + /// — it carries the driver Akka role, replicates nobody's LocalDb, and does not even run the + /// alarm historian — so under a plain "a Primary exists ⇒ stand down" rule every site node + /// suspended its drain in favour of a node that could not possibly deliver its events. The + /// buffer then grows on every node until the capacity wall evicts the oldest: silent, permanent + /// loss of the audit trail the queue exists to protect. + /// + [Fact] + public void A_secondary_whose_peer_is_not_the_primary_keeps_draining() + { + // A Primary exists somewhere in the cluster, but it does not hold this node's rows. + PrimaryGatePolicy.ShouldDrainAlarmHistory(RedundancyRole.Secondary, queueSharingPeerIsPrimary: false) + .ShouldBeTrue("a Secondary must not defer to a Primary that cannot deliver its events"); + } + + /// + /// The divergence, pinned explicitly: with a peer present and no role known, the write gate + /// denies and the drain gate allows. If someone later "harmonises" the two, this fails. + /// + [Fact] + public void The_two_gates_deliberately_disagree_when_the_role_is_unknown_and_a_peer_exists() + { + PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 4).ShouldBeFalse(); + PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: true).ShouldBeTrue(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs index 8100f19f..32531012 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs @@ -1,22 +1,25 @@ +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Shouldly; using Xunit; +using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; +using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; /// /// Verifies the config-gated AddAlarmHistorian registration: when the /// AlarmHistorian section is absent or disabled the -/// default survives; when it is enabled a real wins +/// default survives; when it is enabled a real wins /// (last-registration-wins over the TryAddSingleton Null default). /// public sealed class AlarmHistorianRegistrationTests { - /// A no-op writer the factory hands the Sqlite sink; never actually invoked in these tests. + /// A no-op writer the factory hands the sink; never actually invoked in these tests. private sealed class FakeWriter : IAlarmHistorianWriter { public Task> WriteBatchAsync( @@ -65,34 +68,164 @@ public sealed class AlarmHistorianRegistrationTests } [Fact] - public void Section_enabled_registers_sqlite_sink() + public void Section_enabled_registers_the_localdb_sink() { var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); - var dbPath = Path.Combine(tempDir, "alarm-historian-test.db"); + var dbPath = Path.Combine(tempDir, "node-local.db"); try { - var services = BaseServices(); var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "true", - ["AlarmHistorian:DatabasePath"] = dbPath, + ["LocalDb:Path"] = dbPath, }); + var services = BaseServices(); + services.AddZbLocalDb(config, db => + { + using var connection = db.CreateConnection(); + AlarmSfSchema.Apply(connection); + db.RegisterReplicated(AlarmSfSchema.EventsTable); + }); services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); using (var provider = services.BuildServiceProvider()) { - provider.GetRequiredService().ShouldBeOfType(); + provider.GetRequiredService().ShouldBeOfType(); } // dispose stops the drain loop + releases the SQLite file handle } finally { + SqliteConnection.ClearAllPools(); try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ } } } + /// + /// A node whose LocalDb is not replicated drains its own queue regardless of redundancy role. + /// + /// + /// + /// Standing down is only safe because a peer holds the same rows and will send them + /// instead. Without LocalDb:Replication:PeerAddress there is no such peer: the rows + /// are here and nowhere else, so any deferral means nobody ever delivers them. + /// + /// + /// Found by the Phase 2 live gate. The redundancy role is a CLUSTER-WIDE election while the + /// queue is PAIR-LOCAL, and on the docker-dev rig the elected driver Primary turned out to + /// be a central node — one that carries the driver Akka role, replicates nobody's LocalDb + /// and does not even run the alarm historian. Every site node suspended its drain in favour + /// of a node that could not possibly deliver its events. + /// + /// + [Theory] + // Neither key: an unpaired node. Its rows exist nowhere else. + [InlineData(null, null, true)] + // The dialing half of a pair — gated, because its partner holds the same rows. + [InlineData("http://peer:9001", null, false)] + // The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would + // wrongly class it as unpaired and leave it permanently ungated. + [InlineData(null, "9001", false)] + public async Task Only_an_unreplicated_node_ignores_the_role_view( + string? peerAddress, string? syncListenPort, bool expectDrain) + { + var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var dbPath = Path.Combine(tempDir, "node-local.db"); + + try + { + var config = ConfigFrom(new Dictionary + { + ["AlarmHistorian:Enabled"] = "true", + ["LocalDb:Path"] = dbPath, + ["LocalDb:Replication:PeerAddress"] = peerAddress, + ["LocalDb:SyncListenPort"] = syncListenPort, + }); + + var services = BaseServices(); + services.AddZbLocalDb(config, db => + { + using var connection = db.CreateConnection(); + AlarmSfSchema.Apply(connection); + db.RegisterReplicated(AlarmSfSchema.EventsTable); + }); + + // A role view pinned SHUT: this node is a Secondary and a Primary exists elsewhere. + services.AddSingleton(new StandDownRoleView()); + services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); + + using (var provider = services.BuildServiceProvider()) + { + var sink = provider.GetRequiredService(); + await sink.EnqueueAsync(SampleEvent, CancellationToken.None); + + await ((LocalDbStoreAndForwardSink)sink).DrainOnceAsync(CancellationToken.None); + + var status = sink.GetStatus(); + if (expectDrain) + { + // Delivered and removed — not parked behind a gate that nobody else will open. + status.DrainState.ShouldNotBe(HistorianDrainState.NotPrimary); + status.QueueDepth.ShouldBe(0); + } + else + { + // Held for the peer that shares this queue. + status.DrainState.ShouldBe(HistorianDrainState.NotPrimary); + status.QueueDepth.ShouldBe(1); + } + } + } + finally + { + SqliteConnection.ClearAllPools(); + try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ } + } + } + + /// A view that always says "some other node is the Primary". + private sealed class StandDownRoleView : IRedundancyRoleView + { + public bool ShouldDrainAlarmHistory => false; + + public void Publish(bool shouldDrainAlarmHistory) { } + } + + private static AlarmHistorianEvent SampleEvent => new( + AlarmId: "eq/alarm-1", + EquipmentPath: "line-1/eq-1", + AlarmName: "temp-high", + AlarmTypeName: "LimitAlarm", + Severity: ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High, + EventKind: "Activated", + Message: "temperature high", + User: "system", + Comment: null, + TimestampUtc: new DateTime(2026, 7, 21, 0, 0, 1, DateTimeKind.Utc)); + + /// + /// An enabled historian with no LocalDb registered must fail at resolution, loudly. + /// + /// + /// The queue has nowhere to live without it. Falling back to the Null sink here would look + /// like a working historian while silently discarding every alarm event — the failure mode + /// this whole registration is meant to make impossible. + /// + [Fact] + public void Section_enabled_without_localdb_throws_rather_than_silently_discarding() + { + var services = BaseServices(); + var config = ConfigFrom(new Dictionary { ["AlarmHistorian:Enabled"] = "true" }); + + services.AddAlarmHistorian(config, (_, _) => new FakeWriter()); + + using var provider = services.BuildServiceProvider(); + Should.Throw(() => provider.GetRequiredService()); + } + [Fact] public void Section_binds_drain_capacity_and_retention_knobs() { @@ -112,17 +245,10 @@ public sealed class AlarmHistorianRegistrationTests opts.DeadLetterRetentionDays.ShouldBe(7); } - [Fact] - public void Validate_warns_on_relative_database_path_when_enabled() - { - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db" }; - opts.Validate().ShouldContain(w => w.Contains("DatabasePath")); - } - [Fact] public void Validate_is_silent_when_correctly_configured() { - new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty(); + new AlarmHistorianOptions { Enabled = true }.Validate().ShouldBeEmpty(); } [Fact] @@ -134,39 +260,39 @@ public sealed class AlarmHistorianRegistrationTests [Fact] public void Validate_warns_on_non_positive_drain_interval() { - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DrainIntervalSeconds = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0 }; opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds")); } [Fact] public void Validate_warns_on_non_positive_capacity() { - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", Capacity = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, Capacity = 0 }; opts.Validate().ShouldContain(w => w.Contains("Capacity")); } [Fact] public void Validate_warns_on_non_positive_retention() { - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", DeadLetterRetentionDays = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, DeadLetterRetentionDays = 0 }; opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays")); } [Fact] public void Validate_warns_on_non_positive_max_attempts() { - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "/abs/h.db", MaxAttempts = 0 }; + var opts = new AlarmHistorianOptions { Enabled = true, MaxAttempts = 0 }; opts.Validate().ShouldContain(w => w.Contains("MaxAttempts")); } [Fact] public void Validate_accumulates_multiple_warnings() { - // relative path + non-positive drain interval ⇒ both warnings, not short-circuited on the first. - var opts = new AlarmHistorianOptions { Enabled = true, DatabasePath = "alarm-historian.db", DrainIntervalSeconds = 0 }; + // Two bad knobs ⇒ both warnings, not short-circuited on the first. + var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0, Capacity = 0 }; var warnings = opts.Validate(); - warnings.ShouldContain(w => w.Contains("DatabasePath")); warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds")); + warnings.ShouldContain(w => w.Contains("Capacity")); warnings.Count.ShouldBeGreaterThanOrEqualTo(2); } }