Compare commits

...

20 Commits

Author SHA1 Message Date
Joseph Doherty 3a590a0cb7 refactor(mesh-phase4): retire EfAlarmConditionStateStore + drop dead ScriptedAlarmState table (Task 9)
v2-ci / build (push) Successful in 4m3s
v2-ci / unit-tests (push) Failing after 13m59s
Scripted-alarm condition state lives in LocalDb (Phase 4); the ConfigDb-backed
Ef store + ScriptedAlarmState table are now dead. Removes the test-harness Ef
fallback (a DB-backed node with no store now skips the alarm host), deletes the
store + entity + model config, and drops the table via migration.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:33:11 -04:00
Joseph Doherty 26fad75c8a Merge branch 'feat/mesh-phase4' — per-cluster mesh Phase 4 (cut the driver-side ConfigDb connection)
Driver-only nodes now run with NO ConfigDb connection string: config via
FetchAndCache into LocalDb (Phase 3), scripted-alarm condition state in a
replicated LocalDb table, and central persists their deploy acks. DB-reachability
retired from ServiceLevel on DB-less nodes (a healthy site node holds 240/250 with
central SQL down). OpcUaPublish guard split fixes the driver-only address-space
wipe. Live gate PASSED on docker-dev.

Tasks 0-8 + 1b + 10-11 done; Task 9 (drop dead ScriptedAlarmState table) deferred.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:18:28 -04:00
Joseph Doherty 1281aebfa7 docs(mesh-phase4): live gate PASSED — driver nodes run ConfigDb-free
Deploy sealed green with 4 DB-less site nodes acking (central persists acks);
ServiceLevel held 240 on a site node with central SQL stopped (survive-alone);
restarted site node booted last-known-good from the LocalDb pointer, no ConfigDb
read; alarm_condition_state table live + replicated. Full record in
2026-07-23-mesh-phase4-live-gate.md. Task 9 (drop dead table) deferred.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 14:03:28 -04:00
Joseph Doherty 0170614961 docs(mesh-phase4): Task 10 done; Task 11 live gate in progress
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:22:35 -04:00
Joseph Doherty 0be20ab4fb chore(mesh-phase4): rig site nodes run with no ConfigDb string (FetchAndCache mandatory)
The four driver-only site nodes lose their ConfigDb connection string and hardcode
ConfigSource:Mode=FetchAndCache (Direct is now rejected by the validator for a
driver-only node). Central-1/2 keep ConfigDb + Direct + ConfigServe. Proves the
Phase-4 cut on the rig for the live gate.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:22:18 -04:00
Joseph Doherty 95fa7bd5a0 docs(mesh-phase4): Task 8 done (55a6e1f2 recorded)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:19:27 -04:00
Joseph Doherty 55a6e1f2b2 docs(mesh-phase4): ServiceLevel semantics on DB-less nodes + driver ConfigDb cut
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:18:57 -04:00
Joseph Doherty 6f21213f70 test(mesh-phase4): pin driver-only ConfigDb-free DI graph + fused-node mapper ordering
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 13:10:42 -04:00
Joseph Doherty 4253d20248 docs(mesh-phase4): Task 3 done (73ae8ec5) — address-space wipe fixed
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:58:37 -04:00
Joseph Doherty 73ae8ec5c5 fix(mesh-phase4): OpcUaPublish diff-applies from in-hand bytes with no ConfigDb (no wipe)
A driver-only node has a null dbFactory but a wired applier; the old combined
guard routed it to the raw-sink fallback that wipes the address space and never
re-materialises. Split the guard so in-hand artifact bytes drive the real
diff-and-apply; a missing artifact abandons the rebuild (keep last-known-good,
#485) instead of wiping.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:53:28 -04:00
Joseph Doherty e791832d7c docs(mesh-phase4): Tasks 4+6 done (d9071607); Task 3 confirmed hard prereq
Task 4+6 code review re-surfaced the OpcUaPublishActor raw-sink wipe on a
null-factory node (the exact #485/#486 defect Task 3 fixes) — Task 3 must land
before any node flips to FetchAndCache. Ordering already enforces this
(3 → 7 → 10 → 11).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:48:13 -04:00
Joseph Doherty d907160747 feat(mesh-phase4): DriverHostActor runs ConfigDb-free (LocalDb alarm store, guarded acks)
Nullable ConfigDb factory; UpsertNodeDeploymentState no-ops when absent
(central persists acks from the ApplyAck); scripted-alarm condition state
served from the LocalDb store instead of the ConfigDb-backed Ef store.
Combines Phase-4 Tasks 4 + 6. Removes the interim dbFactory! cast.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:39:41 -04:00
Joseph Doherty 4f4cdd05ec feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:19:44 -04:00
Joseph Doherty 34b613d942 docs(mesh-phase4): Task 2 done; fold review findings into Task 4 brief
Task 2 code review surfaced two unguarded ConfigDb sites reachable on a
driver-only node once Tasks 0+1 landed (SpawnScriptedAlarmHost's Ef store,
unconditional UpsertNodeDeploymentState). Task 4 brief now names them + the
dbFactory! removal; Task 6 re-homes the alarm store to LocalDb.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:07:42 -04:00
Joseph Doherty 9565827275 feat(mesh-phase4): retire DB-reachability from ServiceLevel on DB-less nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 12:06:42 -04:00
Joseph Doherty 5c40908a55 docs(mesh-phase4): add Task 1b (driver-only LDAP mapper) discovered during Task 1
Gating ConfigDb on hasAdmin exposed a 6th driver-side ConfigDb consumer the
§6.1 audit missed: OtOpcUaGroupRoleMapper's ILdapGroupRoleMappingService dep.
Task 7 gains a fused-node ordering pin (Task 1b review gap).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:48:45 -04:00
Joseph Doherty 833e45db9c fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:44:44 -04:00
Joseph Doherty a41582ea6a feat(mesh-phase4): register ConfigDb only on admin-role nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:38:40 -04:00
Joseph Doherty d630a7e267 feat(mesh-phase4): driver-only node must be FetchAndCache (validator)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:29:45 -04:00
Joseph Doherty bc611969a9 docs(mesh-phase4): plan — cut the driver-side ConfigDb connection
Phase 4 of the per-cluster mesh program. Removes the four remaining driver-side
ConfigDb consumers + the registration itself so a driver-only node boots with no
ConfigDb connection string:

- ConfigDb registered iff hasAdmin (Program.cs); driver-only requires none
- driver-only ⇒ FetchAndCache mandatory (validator)
- DbHealthProbeActor not spawned on driver-only; DbReachable=true constant
  (client-visible ServiceLevel change — a healthy DB-less node publishes 240/250
  with central down, per user decision 2026-07-23)
- DriverHostActor: nullable factory, drop redundant SQL ack-writes (central's
  PersistNodeAck is the ack system of record)
- EfAlarmConditionStateStore → LocalDbAlarmConditionStateStore (new replicated
  alarm_condition_state table)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-23 11:22:48 -04:00
37 changed files with 4353 additions and 576 deletions
+34
View File
@@ -355,6 +355,40 @@ See `docs/Configuration.md` (`LocalDb` section), `docs/Redundancy.md` (pair-loca
cache), `docs/AlarmHistorian.md`, and the runbook cache), `docs/AlarmHistorian.md`, and the runbook
`docs/operations/2026-07-20-localdb-pair-replication.md`. `docs/operations/2026-07-20-localdb-pair-replication.md`.
## Phase 4 — cut the driver-side ConfigDb connection
Per-cluster mesh **Phase 4** finished the job Phase 3 started: a **driver-only** node (`Cluster:Roles`
has `driver`, not `admin`) now boots with **no `ConfigDb` connection string at all**. `Program.cs`
gates `AddOtOpcUaConfigDb` (and its health check) on `hasAdmin`; a fused `admin,driver` node is
unaffected (it keeps ConfigDb via its admin role). Consequences:
- **`ConfigSource:Mode` must be `FetchAndCache` on a driver-only node** — `ConfigSourceOptionsValidator`
fails host start if such a node is left on `Direct` (there is nothing to read Direct from). A fused
node may stay `Direct`.
- **ServiceLevel semantics change — client-visible.** With no ConfigDb, `DbHealthProbeActor` is not
spawned (`ServiceCollectionExtensions.WithOtOpcUaRuntimeActors` skips it when the resolved db
factory is null), and `OpcUaPublishActor` runs `dbLess`: `DbReachable` is fed constant `true` and
`Stale` comes from the redundancy-snapshot age alone. A healthy driver-only node therefore publishes
**240** (or **250** as driver Primary) even with central SQL unreachable — the survive-alone posture
— where a `Direct`/DB-backed node in the same state drops to 100/0. `ServiceLevelCalculator` itself
is unchanged; only the inputs a DB-less node feeds it changed. See `docs/Redundancy.md` §"DB-less
(driver-only) nodes".
- **Scripted-alarm Part 9 condition state moved to LocalDb.** `LocalDbAlarmConditionStateStore`
(replicated `alarm_condition_state` table, pair-local like the Phase-2 alarm S&F buffer) replaces
`EfAlarmConditionStateStore` on every driver-role node, fused central included. The DB-backed
`ScriptedAlarmState` table is now unused (dropping it is a deferred follow-up, tracked as Task 9).
- **Central persists deploy acks.** `DriverHostActor` no longer writes `NodeDeploymentState` to SQL on
a driver-only node — `ConfigPublishCoordinator.PersistNodeAck` (already fed by every ApplyAck) is
the system of record.
- **LDAP group→role DB grants don't reach driver-only nodes.** They register the empty
`NullLdapGroupRoleMappingService` (no ConfigDb to read `LdapGroupRoleMapping` from), so
`OtOpcUaGroupRoleMapper` falls back to the `Security:Ldap:GroupToRole` appsettings baseline only —
the same rows a driver node never received via config either, so this is not a regression.
Code-complete on `feat/mesh-phase4`; the table-drop (Task 9) and the live gate (Task 10) are still
open. See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
## LDAP Authentication ## LDAP Authentication
The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide. The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide.
+20 -18
View File
@@ -359,16 +359,18 @@ services:
migrator: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully }
environment: environment:
OTOPCUA_ROLES: "driver" OTOPCUA_ROLES: "driver"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" # Per-cluster mesh Phase 4: driver-only site nodes hold NO ConfigDb connection string. Their
# config comes from central via ConfigSource:Mode=FetchAndCache (gRPC fetch → LocalDb cache), their
# scripted-alarm condition state lives in LocalDb, and central persists their deploy acks. Direct
# mode is invalid here — ConfigSourceOptionsValidator fails start for a driver-only node set Direct.
Cluster__Hostname: "0.0.0.0" Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053" Cluster__Port: "4053"
Cluster__PublicHostname: "site-a-1" Cluster__PublicHostname: "site-a-1"
# Per-cluster mesh Phase 3 dark switch. Default Direct (reads central SQL). Flip the site nodes to # FetchAndCache is MANDATORY for driver-only nodes (validator-enforced): they fetch the deployed
# FetchAndCache with `OTOPCUA_CONFIG_MODE=FetchAndCache docker compose up`: they then fetch the # artifact from central over gRPC (endpoints below) and read ONLY the LocalDb cache — no central
# deployed artifact from central over gRPC (endpoints below) and read ONLY the LocalDb cache — no # SQL config read (they have no connection string). Central stays Direct + keeps ConfigServe.
# central SQL config read. Central stays Direct, so this env var moves ONLY the four site nodes.
# ConfigSource:ApiKey must equal central's ConfigServe:ApiKey (the interceptor is fail-closed). # ConfigSource:ApiKey must equal central's ConfigServe:ApiKey (the interceptor is fail-closed).
ConfigSource__Mode: "${OTOPCUA_CONFIG_MODE:-Direct}" ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055" ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055" ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key" ConfigSource__ApiKey: "configserve-docker-dev-key"
@@ -450,13 +452,13 @@ services:
migrator: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully }
environment: environment:
OTOPCUA_ROLES: "driver" OTOPCUA_ROLES: "driver"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" # Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0" Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053" Cluster__Port: "4053"
Cluster__PublicHostname: "site-a-2" Cluster__PublicHostname: "site-a-2"
# Per-cluster mesh Phase 3 dark switch (see site-a-1). Default Direct; OTOPCUA_CONFIG_MODE=FetchAndCache # Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# flips it to fetch the artifact from central over gRPC and read only the LocalDb cache. # ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "${OTOPCUA_CONFIG_MODE:-Direct}" ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055" ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055" ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key" ConfigSource__ApiKey: "configserve-docker-dev-key"
@@ -527,13 +529,13 @@ services:
migrator: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully }
environment: environment:
OTOPCUA_ROLES: "driver" OTOPCUA_ROLES: "driver"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" # Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0" Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053" Cluster__Port: "4053"
Cluster__PublicHostname: "site-b-1" Cluster__PublicHostname: "site-b-1"
# Per-cluster mesh Phase 3 dark switch (see site-a-1). Default Direct; OTOPCUA_CONFIG_MODE=FetchAndCache # Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# flips it to fetch the artifact from central over gRPC and read only the LocalDb cache. # ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "${OTOPCUA_CONFIG_MODE:-Direct}" ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055" ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055" ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key" ConfigSource__ApiKey: "configserve-docker-dev-key"
@@ -587,13 +589,13 @@ services:
migrator: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully }
environment: environment:
OTOPCUA_ROLES: "driver" OTOPCUA_ROLES: "driver"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" # Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0" Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053" Cluster__Port: "4053"
Cluster__PublicHostname: "site-b-2" Cluster__PublicHostname: "site-b-2"
# Per-cluster mesh Phase 3 dark switch (see site-a-1). Default Direct; OTOPCUA_CONFIG_MODE=FetchAndCache # Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# flips it to fetch the artifact from central over gRPC and read only the LocalDb cache. # ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "${OTOPCUA_CONFIG_MODE:-Direct}" ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055" ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055" ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key" ConfigSource__ApiKey: "configserve-docker-dev-key"
+16 -1
View File
@@ -162,9 +162,24 @@ On the docker-dev rig every node carries these keys already; flip the whole rig
On the docker-dev rig the central pair carries `ConfigServe` (port `4055`, dev key) and stays `Direct`; the four site nodes carry `ConfigSource` (endpoints + matching key) defaulting to `Direct`. Flip only the site nodes with `OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker compose up` — central keeps SQL and models the target topology. On the docker-dev rig the central pair carries `ConfigServe` (port `4055`, dev key) and stays `Direct`; the four site nodes carry `ConfigSource` (endpoints + matching key) defaulting to `Direct`. Flip only the site nodes with `OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker compose up` — central keeps SQL and models the target topology.
**`FetchAndCache` is mandatory on a driver-only node — per-cluster mesh Phase 4.** `Program.cs` now
registers `AddOtOpcUaConfigDb` only when the node carries the `admin` role, so a **driver-only**
node (`Cluster:Roles` has `driver`, not `admin`) holds **no `ConfigDb` connection string at all**
there is nothing for `Direct` to read. `ConfigSourceOptionsValidator` enforces this at
`ValidateOnStart`: a driver-only node whose `ConfigSource:Mode` is `Direct` **fails host start**
with an error naming the fix (`ConfigSource:Mode=FetchAndCache`). A **fused** `admin,driver` node
still owns its ConfigDb via the admin role and may stay `Direct` — the validator only fires on
`driver` without `admin`. On a driver-only node the deployed configuration, the scripted-alarm Part
9 condition state (`LocalDbAlarmConditionStateStore`, replacing the ConfigDb-backed
`EfAlarmConditionStateStore`), and the alarm store-and-forward buffer all live in the node's
LocalDb; central remains the system of record for deploy acks (`ConfigPublishCoordinator.
PersistNodeAck`) and LDAP group→role DB grants (a driver-only node maps LDAP groups to roles from
the `Security:Ldap:GroupToRole` appsettings baseline only — see `docs/Redundancy.md` and
`CLAUDE.md` §"Phase 4" for the full picture, including the client-visible ServiceLevel change).
### `ConnectionStrings``ConfigDb` ### `ConnectionStrings``ConfigDb`
- **Purpose:** the central Config DB connection string. **Required for every role**`Program.cs` calls `AddOtOpcUaConfigDb` unconditionally. - **Purpose:** the central Config DB connection string. **Required on admin-role nodes only**`Program.cs` calls `AddOtOpcUaConfigDb` when `hasAdmin` (per-cluster mesh Phase 4). A driver-only node holds no `ConfigDb` connection string; it fetches its configuration via `ConfigSource:Mode=FetchAndCache` instead (see above). A fused `admin,driver` node still requires it.
- **Bound by:** `AddOtOpcUaConfigDb(config)` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs`). The connection-string name constant is `ConnectionStringName = "ConfigDb"`, read via `configuration.GetConnectionString("ConfigDb")`. If absent, startup throws with a message pointing to either `appsettings.json` or the `OTOPCUA_CONFIG_CONNECTION` env var. - **Bound by:** `AddOtOpcUaConfigDb(config)` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs`). The connection-string name constant is `ConnectionStringName = "ConfigDb"`, read via `configuration.GetConnectionString("ConfigDb")`. If absent, startup throws with a message pointing to either `appsettings.json` or the `OTOPCUA_CONFIG_CONNECTION` env var.
- **Shape:** standard `ConnectionStrings:ConfigDb` SQL Server connection string. There is no checked-in default in the thin `appsettings*.json` — supply it per environment. - **Shape:** standard `ConnectionStrings:ConfigDb` SQL Server connection string. There is no checked-in default in the thin `appsettings*.json` — supply it per environment.
+28 -1
View File
@@ -16,7 +16,7 @@ The runtime pieces live in:
| `OpcUaPublishActor` | `OtOpcUa.Runtime.OpcUa` | Per-driver-node; subscribes to the `redundancy-state` topic, computes a health-aware ServiceLevel byte via `ServiceLevelCalculator` (see below), and forwards it to `IServiceLevelPublisher`. | | `OpcUaPublishActor` | `OtOpcUa.Runtime.OpcUa` | Per-driver-node; subscribes to the `redundancy-state` topic, computes a health-aware ServiceLevel byte via `ServiceLevelCalculator` (see below), and forwards it to `IServiceLevelPublisher`. |
| `IServiceLevelPublisher` / `SdkServiceLevelPublisher` | `OtOpcUa.Commons.OpcUa` / `OtOpcUa.OpcUaServer` | Writes the byte into the SDK's `Server.ServiceLevel` Variable. Production binds `DeferredServiceLevelPublisher`, which swaps in the real `SdkServiceLevelPublisher` once the SDK is up (it needs `IServerInternal`, available only after `StandardServer.Start`); until then writes route through `NullServiceLevelPublisher`. | | `IServiceLevelPublisher` / `SdkServiceLevelPublisher` | `OtOpcUa.Commons.OpcUa` / `OtOpcUa.OpcUaServer` | Writes the byte into the SDK's `Server.ServiceLevel` Variable. Production binds `DeferredServiceLevelPublisher`, which swaps in the real `SdkServiceLevelPublisher` once the SDK is up (it needs `IServerInternal`, available only after `StandardServer.Start`); until then writes route through `NullServiceLevelPublisher`. |
| `ServiceLevelCalculator` | `OtOpcUa.Cluster.Redundancy` (`Core.Cluster`) | Pure function `(NodeHealthInputs) → byte` — the DB/probe-aware tiering (see truth table below). Covered by `ServiceLevelCalculatorTests`. **Now the live publish path**`OpcUaPublishActor` calls it on every `HealthTick` and `RedundancyStateChanged` event. Moved to `Core.Cluster` so Runtime can reach it without a Runtime→ControlPlane reference. | | `ServiceLevelCalculator` | `OtOpcUa.Cluster.Redundancy` (`Core.Cluster`) | Pure function `(NodeHealthInputs) → byte` — the DB/probe-aware tiering (see truth table below). Covered by `ServiceLevelCalculatorTests`. **Now the live publish path**`OpcUaPublishActor` calls it on every `HealthTick` and `RedundancyStateChanged` event. Moved to `Core.Cluster` so Runtime can reach it without a Runtime→ControlPlane reference. |
| `DbHealthProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; runs `SELECT 1` against ConfigDb every 5s. Read by the health endpoint AND by `OpcUaPublishActor` (the `DbReachable` ServiceLevel input). | | `DbHealthProbeActor` | `OtOpcUa.Runtime.Health` | Runs `SELECT 1` against ConfigDb every 5s. Read by the health endpoint AND by `OpcUaPublishActor` (the `DbReachable` ServiceLevel input). **Spawned only when a ConfigDb is present** (admin-role or fused admin+driver nodes) — a driver-only node has no `IDbContextFactory<OtOpcUaConfigDbContext>` to probe, so this actor is not spawned there at all; see "DB-less nodes" below. |
| `PeerProbeSupervisor` | `OtOpcUa.Runtime.Health` | Per-node; subscribes to the `redundancy-state` topic and maintains one `PeerOpcUaProbeActor` child per OTHER driver-role peer (spawn on join, stop on departure), so every node is continuously probed by its peers. | | `PeerProbeSupervisor` | `OtOpcUa.Runtime.Health` | Per-node; subscribes to the `redundancy-state` topic and maintains one `PeerOpcUaProbeActor` child per OTHER driver-role peer (spawn on join, stop on departure), so every node is continuously probed by its peers. |
| `PeerOpcUaProbeActor` | `OtOpcUa.Runtime.Health` | Spawned by `PeerProbeSupervisor`; pings a peer `opc.tcp://peer:4840` with a TCP connect (2s timeout) and publishes `OpcUaProbeResult` on the `redundancy-state` topic. A full secure-channel Hello handshake is a possible future upgrade; the TCP connect is the current real probe. | | `PeerOpcUaProbeActor` | `OtOpcUa.Runtime.Health` | Spawned by `PeerProbeSupervisor`; pings a peer `opc.tcp://peer:4840` with a TCP connect (2s timeout) and publishes `OpcUaProbeResult` on the `redundancy-state` topic. A full secure-channel Hello handshake is a possible future upgrade; the TCP connect is the current real probe. |
| `ClusterRoleInfo` | `OtOpcUa.Cluster` | Live view of cluster membership + role-leader; exposes `IClusterRoleInfo` to the rest of the host. (Akka's role-leader is *not* what elects the redundancy Primary — see below.) | | `ClusterRoleInfo` | `OtOpcUa.Cluster` | Live view of cluster membership + role-leader; exposes `IClusterRoleInfo` to the rest of the host. (Akka's role-leader is *not* what elects the redundancy Primary — see below.) |
@@ -56,6 +56,33 @@ The resulting truth table (all tiers are now reachable at runtime):
> by the +10 bonus. Clients with the standard "pick highest ServiceLevel" > by the +10 bonus. Clients with the standard "pick highest ServiceLevel"
> heuristic continue to prefer the primary. > heuristic continue to prefer the primary.
#### DB-less (driver-only) nodes — per-cluster mesh Phase 4
**Client-visible change.** Per-cluster mesh Phase 4 cut the ConfigDb connection off driver-only
nodes (Akka role `driver`, not `admin`) — `Program.cs` registers `AddOtOpcUaConfigDb` only when
`hasAdmin`. With no ConfigDb to probe, `DbHealthProbeActor` is **not spawned** on such a node
(`ServiceCollectionExtensions.WithOtOpcUaRuntimeActors` skips it whenever the resolved
`IDbContextFactory<OtOpcUaConfigDbContext>` is null), and `OpcUaPublishActor` runs in a `dbLess`
mode instead of the health-aware path above:
- `DbReachable` is fed **constant `true`** — LocalDb is the config store and is in-process, so it
can never be "unreachable" the way a remote SQL Server can.
- `Stale` is derived from the `redundancy-state` snapshot age alone (`(now snapshotEntry.AsOfUtc)
> 30 s`) — there is no DB-health term to fold in.
- `OpcUaProbeOk` and `IsDriverPrimary` are unchanged (peer OPC UA probe, oldest-Up-member election).
**Consequence:** a healthy driver-only node publishes **240** as a follower or **250** as the
driver Primary **even when central SQL is unreachable** — the survive-alone posture the fetch-and-
cache design (Phase 3) exists to support. Contrast this with a `Direct`/DB-backed node (fused
admin+driver, or any node still reading ConfigDb directly): an unreachable ConfigDb there still
drives `DbReachable=false`, which forces `Stale=true` in the derivation above and the node drops to
**100** (or **0** once the sample itself goes stale) via the health-aware tiering, not 240/250.
Same byte, opposite meaning depending on which kind of node is publishing it — a client or operator
reading ServiceLevel in isolation cannot tell "central is down and I don't care" (driver-only, 240)
from "central is down and I'm degraded" (DB-backed, 100/0) without also knowing the node's role
shape. `ServiceLevelCalculator.Compute` itself is unchanged; only the inputs a DB-less node feeds
it changed — see `OpcUaPublishActor.RecomputeServiceLevel`'s `_dbLess` branch.
#### Backward-compatible fallback (legacy seam) #### Backward-compatible fallback (legacy seam)
A node with no `DbHealthStatus` wired (e.g. early bootstrap window before the A node with no `DbHealthStatus` wired (e.g. early bootstrap window before the
@@ -141,7 +141,7 @@ admin-change refresh), clusters know central from appsettings (static, restart t
flow over ClusterClient (DPS paths deleted or dark-switched), including an Ask timing out cleanly flow over ClusterClient (DPS paths deleted or dark-switched), including an Ask timing out cleanly
against a stopped node. against a stopped node.
### Phase 3 — Config fetch-and-cache from central — **CODE-COMPLETE 2026-07-23 (Tasks 08; live gate Task 10 pending)** ### Phase 3 — Config fetch-and-cache from central — **DONE 2026-07-23 (merged `d01b0695`, live gate PASSED, pushed to origin)**
**Decisions taken:** the deploy artifact is served **by central over a gRPC fetch RPC** **Decisions taken:** the deploy artifact is served **by central over a gRPC fetch RPC**
(`DeploymentArtifactService.Fetch`, streamed chunks — chosen over token-gated HTTP), authenticated by (`DeploymentArtifactService.Fetch`, streamed chunks — chosen over token-gated HTTP), authenticated by
a **shared node bearer key** (`ConfigServe:ApiKey == ConfigSource:ApiKey`, `FixedTimeEquals`, a **shared node bearer key** (`ConfigServe:ApiKey == ConfigSource:ApiKey`, `FixedTimeEquals`,
@@ -160,13 +160,14 @@ config SQL read); #485 coverage; and a **real two-Host gRPC boundary test** (rig
wrong-key control returns null, failover). Chunking, SHA-256 verify, newest-2 retention, pair wrong-key control returns null, failover). Chunking, SHA-256 verify, newest-2 retention, pair
replication carry over unchanged. **Phase boundary held:** config **reads** only — the replication carry over unchanged. **Phase boundary held:** config **reads** only — the
`NodeDeploymentState` ack-row write, `DbHealthProbe`, `EfAlarmConditionStateStore` stay for Phase 4. `NodeDeploymentState` ack-row write, `DbHealthProbe`, `EfAlarmConditionStateStore` stay for Phase 4.
**Remaining (Tasks 910):** rig config + docs (done) and the live gate — deploy on a site pair flipped **Live gate PASSED:** deployed on a site pair flipped to `FetchAndCache` with central SQL stopped
to `FetchAndCache` with central SQL stopped mid-fetch retry lands; #485 last-known-good re-proven on mid-fetch retry landed; #485 last-known-good re-proven on the new path. Merged to master
the new path. `d01b0695` and pushed to origin; the scadaproj umbrella index was updated + pushed (`b5e7bc8`) in the
**Exit gate:** live gate green on the rig; a driver node with an empty LocalDb and reachable central same pass.
boots into the current config; with central down it boots last-known-good. **Exit gate — MET:** live gate green on the rig; a driver node with an empty LocalDb and reachable
central boots into the current config; with central down it boots last-known-good.
### Phase 4 — Cut the driver-side ConfigDb connection ### Phase 4 — Cut the driver-side ConfigDb connection — **DONE 2026-07-23 (live gate PASSED; Tasks 08 + 1b + 1011; Task 9 table-drop deferred)**
**Scope (design §6.1 audit table):** re-home `EfAlarmConditionStateStore` to LocalDb (pair-local **Scope (design §6.1 audit table):** re-home `EfAlarmConditionStateStore` to LocalDb (pair-local
state, same journey as the Phase-2 alarm S&F buffer); **resolve the `DbHealthProbeActor` state, same journey as the Phase-2 alarm S&F buffer); **resolve the `DbHealthProbeActor`
question** — driver nodes have no DB to probe, and DB health currently feeds ServiceLevel tiering, question** — driver nodes have no DB to probe, and DB health currently feeds ServiceLevel tiering,
@@ -176,6 +177,27 @@ transports) — this is a client-visible ServiceLevel semantics change and must
design) and re-source it; registration cleanup in `ServiceCollectionExtensions`; driver-role design) and re-source it; registration cleanup in `ServiceCollectionExtensions`; driver-role
`Program.cs` branch registers no EF ConfigDb context at all (mirror ScadaBridge's central-only `Program.cs` branch registers no EF ConfigDb context at all (mirror ScadaBridge's central-only
`AddConfigurationDatabase`). `AddConfigurationDatabase`).
**Shipped (branch `feat/mesh-phase4`, Tasks 07 + 1b):** `Program.cs` gates `AddOtOpcUaConfigDb` +
the ConfigDb health check on `hasAdmin`, so a driver-only node registers no ConfigDb connection
string at all; `ConfigSourceOptionsValidator` fails host start if a driver-only node's
`ConfigSource:Mode` is left `Direct` (fused `admin,driver` may stay `Direct`); the DB-health
question resolved as **retire, don't replace**`DbHealthProbeActor` is simply not spawned when no
ConfigDb is present, and `OpcUaPublishActor` runs a `dbLess` mode feeding `DbReachable=true`
constant with staleness from the redundancy-snapshot age alone (documented as the client-visible
change in `docs/Redundancy.md`); `LocalDbAlarmConditionStateStore` (replicated
`alarm_condition_state` table) replaces `EfAlarmConditionStateStore` on every driver-role node;
`DriverHostActor` no longer writes `NodeDeploymentState` on a driver-only node (central's
`ConfigPublishCoordinator.PersistNodeAck` was already the ack system of record); driver-only nodes
register `NullLdapGroupRoleMappingService` so LDAP group→role falls back to the
`Security:Ldap:GroupToRole` appsettings baseline (DB grants were never delivered to driver nodes via
config anyway). Docs updated: `docs/Redundancy.md`, `docs/Configuration.md`, `CLAUDE.md`.
**Deferred:** Task 9 — dropping the now-unused `ScriptedAlarmState` ConfigDb table (a follow-up, not
a blocker; the table is simply unread).
**Remaining (Tasks 1011):** rig config for a driver-only node with no `ConfigDb` connection string,
and the live gate itself — a driver pair runs a full deploy + alarm + historian cycle with no
ConfigDb configured at all, confirming ServiceLevel 240/250 while central SQL is down and
grep-level proof no driver-branch service can resolve the ConfigDb context. **Not yet run — treat
Phase 4 as code-complete, not verified, until this lands.**
**Exit gate:** its own live gate — a driver pair runs a full deploy + alarm + historian cycle with **Exit gate:** its own live gate — a driver pair runs a full deploy + alarm + historian cycle with
**no ConfigDb connection string configured at all**; grep-level proof no driver-branch service can **no ConfigDb connection string configured at all**; grep-level proof no driver-branch service can
resolve the ConfigDb context. resolve the ConfigDb context.
@@ -243,8 +265,8 @@ resource sizing on the site VMs should be checked once both products run the ful
| Prereq: selfform-fallback + manual-failover plan | **DONE 2026-07-22**, merged `a78425ea`. Shipped *not* as the planned `SelfFormAfter` watchdog — the live gate caught that islanding a manually-failed-over node — but as self-first seed ordering + `AkkaClusterOptionsValidator`. Phase 6's seed-ordering scope is therefore already discharged. | | Prereq: selfform-fallback + manual-failover plan | **DONE 2026-07-22**, merged `a78425ea`. Shipped *not* as the planned `SelfFormAfter` watchdog — the live gate caught that islanding a manually-failed-over node — but as self-first seed ordering + `AkkaClusterOptionsValidator`. Phase 6's seed-ordering scope is therefore already discharged. |
| 1 ClusterNode columns + DB ack set | **DONE 2026-07-22**, merged `7654f24d`. Deploy-seal semantics changed as flagged, but the escape hatch is **`ClusterNode.MaintenanceMode`**, not `Enabled=0` — the live gate found `Enabled=0` is rejected by `DraftValidator.ValidateClusterTopology` on any 2-node pair. Gate record: `2026-07-22-mesh-phase1-live-gate.md`. | | 1 ClusterNode columns + DB ack set | **DONE 2026-07-22**, merged `7654f24d`. Deploy-seal semantics changed as flagged, but the escape hatch is **`ClusterNode.MaintenanceMode`**, not `Enabled=0` — the live gate found `Enabled=0` is rejected by `DraftValidator.ValidateClusterTopology` on any 2-node pair. Gate record: `2026-07-22-mesh-phase1-live-gate.md`. |
| 2 ClusterClient transport | **DONE 2026-07-22** (live gate PASSED, shipped dark) — shipped dark (`MeshTransport:Mode=Dps` default), both comm actors registered in both modes. Corrections: `SendToAll` not `Send`, one fleet-wide client not one per cluster, `/user/node-communication` not `/user/cluster-communication`. The gate's "Ask timing out" item does not exist to test — no cross-boundary Ask in this phase; see the phase section. | | 2 ClusterClient transport | **DONE 2026-07-22** (live gate PASSED, shipped dark) — shipped dark (`MeshTransport:Mode=Dps` default), both comm actors registered in both modes. Corrections: `SendToAll` not `Send`, one fleet-wide client not one per cluster, `/user/node-communication` not `/user/cluster-communication`. The gate's "Ask timing out" item does not exist to test — no cross-boundary Ask in this phase; see the phase section. |
| 3 fetch-and-cache | **CODE-COMPLETE 2026-07-23** (Tasks 08 merged to the phase branch, all green + sabotage-checked; live gate Task 10 pending). gRPC fetch RPC + shared node key; dark switch `ConfigSource:Mode` (Direct default). Rig flip: `OTOPCUA_CONFIG_MODE=FetchAndCache` on the site nodes. See `2026-07-22-mesh-phase3-config-fetch-and-cache.md`. | | 3 fetch-and-cache | **DONE 2026-07-23**, merged `d01b0695`, live gate PASSED, pushed to origin (scadaproj umbrella index updated + pushed `b5e7bc8`). gRPC fetch RPC + shared node key; dark switch `ConfigSource:Mode` (Direct default). Rig flip: `OTOPCUA_CONFIG_MODE=FetchAndCache` on the site nodes. See `2026-07-22-mesh-phase3-config-fetch-and-cache.md`. |
| 4 cut driver ConfigDb | not started | | 4 cut driver ConfigDb | **DONE 2026-07-23 — live gate PASSED** on `feat/mesh-phase4` (Tasks 08 + 1b + 1011; Task 9 table-drop deferred). ConfigDb admin-only; driver-only ⇒ FetchAndCache (validator); `DbHealthProbeActor` not spawned driver-only (client-visible ServiceLevel 240/250 with central SQL down — proven live); alarm condition state in replicated LocalDb `alarm_condition_state`; central persists acks; `OpcUaPublish` guard split fixes the driver-only address-space wipe; driver-only LDAP maps from appsettings only (6th consumer found mid-phase). Gate: deploy sealed green w/ 4 DB-less site nodes acking, ServiceLevel held 240 w/ SQL down, restart booted last-known-good from the LocalDb pointer. See `2026-07-23-mesh-phase4-cut-driver-configdb.md` + `2026-07-23-mesh-phase4-live-gate.md`. |
| 5 gRPC telemetry | not started | | 5 gRPC telemetry | not started |
| 6 mesh partition + co-location | not started | | 6 mesh partition + co-location | not started |
| 7 drill + live gates | not started | | 7 drill + live gates | not started |
@@ -0,0 +1,536 @@
# Per-Cluster Mesh Phase 4 — Cut the Driver-Side ConfigDb Connection
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** A driver-only OtOpcUa node runs a full deploy + alarm + historian cycle with **no
`ConfigDb` connection string configured at all** — LocalDb is its steady-state config + alarm-state
store, and central persists its deploy acks. Grep-level proof: no driver-branch service can resolve
`OtOpcUaConfigDbContext`.
**Architecture:** Phase 3 already gave driver nodes their config from central (fetch-and-cache into
LocalDb) behind the `ConfigSource:Mode=FetchAndCache` dark switch. Phase 4 removes the *remaining*
four driver-side ConfigDb consumers and the registration itself, so a driver-only host boots with the
`ConfigDb` connection string absent. The fused **central** node keeps ConfigDb via its `admin` role —
this phase gates the whole registration on `hasAdmin`, never on `hasDriver`.
**Tech stack:** .NET 10, Akka.NET, EF Core (ConfigDb, SQL Server — central only after this phase),
`ZB.MOM.WW.LocalDb` (SQLite, replicated), xUnit + Shouldly.
---
## Design decisions (settled — do not relitigate)
1. **ConfigDb is registered iff `hasAdmin`.** `Program.cs:126`'s unconditional `AddOtOpcUaConfigDb`
moves inside a `hasAdmin` guard. A driver-only node requires **no** `ConfigDb` connection string
and must not throw for its absence. Fused `admin,driver` central nodes are unaffected (admin owns
the DB; driver actors on that node may still be handed a non-null factory).
2. **Driver-only ⇒ `FetchAndCache` is mandatory.** `Direct` mode reads central SQL, which a driver-only
node no longer has. `ConfigSourceOptionsValidator` fails start for a `driver`-role, non-`admin` node
whose `Mode` is `Direct`. (A fused `admin,driver` node may stay `Direct` — it has the DB.)
3. **Db-reachability retires from ServiceLevel on driver-only nodes** (user decision 2026-07-23):
LocalDb *is* the config store and is in-process, so `DbReachable` is treated as a constant `true`
on nodes with no `DbHealthProbeActor`. Staleness already carries "running behind on config." A
healthy site node serving last-known-good with central down publishes 240/250 — the survive-alone
posture. **This is a client-visible ServiceLevel semantics change** — documented in
`docs/Redundancy.md` + the interop note.
4. **Central is the system of record for deploy acks.** `ConfigPublishCoordinator.PersistNodeAck`
already writes `NodeDeploymentState` from every `ApplyAck` it receives over the transport, and seeds
the `Applying` rows at dispatch. So `DriverHostActor`'s direct `UpsertNodeDeploymentState` SQL writes
are **redundant** and are removed. The bootstrap `NodeDeploymentState` read is already replaced by
the LocalDb `deployment_pointer` for `FetchAndCache` (Phase 3).
5. **`ScriptedAlarmState` (Part 9 condition state) → LocalDb, wholesale for driver-role nodes.** It is
read only by `EfAlarmConditionStateStore`; AdminUI `/alerts` uses live SignalR, not the table. A new
replicated `alarm_condition_state` LocalDb table + `LocalDbAlarmConditionStateStore` replaces the EF
store on **every** driver-role node (fused central included), same journey Phase 2 made for the alarm
S&F buffer. The dead ConfigDb `ScriptedAlarmState` table is dropped in a final trivial migration.
6. **`OpcUaPublishActor._dbFactory` becomes null on driver-only nodes.** Its only ConfigDb use is
`LoadArtifact`/`LoadLatestArtifact` (the #485 artifact-blob reads); `FetchAndCache` already passes
`msg.Artifact` in-hand so the fallback never fires. Phase 4 asserts the null-factory path never reads
ConfigDb and every driver-only rebuild carries bytes.
## The five consumers (design §6.1 audit) and their Phase-4 disposition
| Consumer | Current ConfigDb use | Phase-4 disposition | Task |
|---|---|---|---|
| `DriverHostActor` | `UpsertNodeDeploymentState` writes (×9), bootstrap `NodeDeploymentState` read, `EfAlarmConditionStateStore` ctor | drop SQL ack-writes (central persists); nullable factory; LocalDb condition store | 4, 56 |
| `EfAlarmConditionStateStore` | Part 9 condition state in ConfigDb `ScriptedAlarmState` | → `LocalDbAlarmConditionStateStore` (new replicated table) | 5, 6 |
| `DbHealthProbeActor` | `SELECT 1` vs ConfigDb → `DbReachable` ServiceLevel input | not spawned on driver-only; `DbReachable=true` constant | 2, 3 |
| `OpcUaPublishActor` | `LoadArtifact`/`LoadLatestArtifact` | nullable factory; rebuild always carries bytes | 3 |
| `ServiceCollectionExtensions` (Runtime + Configuration) + `Program.cs` | registration | `hasAdmin`-gated; nullable threading | 1, 7 |
---
## Task 0: `ConfigSourceOptionsValidator` — driver-only ⇒ FetchAndCache
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Configuration/ConfigSourceOptionsValidatorTests.cs`
(or the existing validator test file — locate with `grep -rl ConfigSourceOptionsValidator tests`)
**Context:** The validator today checks the `FetchAndCache` fields (endpoints/key). Phase 4 adds the
role-conditional rule. The validator must be given the node's roles — check how it reads them today
(likely `IConfiguration["Cluster:Roles"]` or an injected role view); mirror `AkkaClusterOptionsValidator`,
which already matches on roles at boot.
**Step 1: Write the failing test** — a `driver`-role, non-`admin` node with `Mode=Direct` → validation
fails with a message naming the flag; a fused `admin,driver` node with `Mode=Direct` → passes; a
driver-only node with `Mode=FetchAndCache` → passes.
**Step 2:** Run it, confirm it fails (rule not yet present).
**Step 3: Implement** the rule: `roles.Contains("driver") && !roles.Contains("admin") && Mode==Direct`
`ValidateOptionsResult.Fail(...)`. Read roles the same way the validator/`AddValidatedOptions` wiring
already does; do not invent a new source.
**Step 4:** Run tests, confirm pass.
**Step 5: Commit** — `feat(mesh-phase4): driver-only node must be FetchAndCache (validator)`.
---
## Task 1: `Program.cs` — gate ConfigDb registration + health check on `hasAdmin`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (boot path; other tasks build on the nullable factory it introduces)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:125-126` (the unconditional `AddOtOpcUaConfigDb`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs:27-31`
(`DatabaseHealthCheck<OtOpcUaConfigDbContext>` — driver-only has no ConfigDb to probe)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverOnlyNoConfigDbBootTests.cs` (create)
**Context:** `AddOtOpcUaConfigDb` throws if the `ConfigDb` connection string is absent. Line 126 comment
says "always registered regardless of role. ConfigDb is required for everything" — that statement is
what Phase 4 falsifies. Gate the call on `hasAdmin`. Anything downstream that resolves
`IDbContextFactory<OtOpcUaConfigDbContext>` non-optionally must already be `hasAdmin`-only or become
nullable (Tasks 27 handle the driver-branch resolvers). Grep `GetRequiredService<...ConfigDbContext...>`
and `GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>` across the Host to find any that
would now throw on a driver-only node; each must move under `hasAdmin` or switch to `GetService`.
**Step 1: Write the failing test** — build a Host `WebApplication` with roles `driver` only and **no**
`ConfigDb` connection string; assert it builds + starts without throwing, and that
`services.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>()` is `null`. (Model the harness on
`DeploymentArtifactFetchBoundaryTests.StartServerAsync` — minimal builder, in-memory where needed.)
**Step 2:** Run it — fails today (`AddOtOpcUaConfigDb` throws "Connection string 'ConfigDb' is required").
**Step 3: Implement** — wrap `AddOtOpcUaConfigDb` in `if (hasAdmin)`; wrap the ConfigDb health check in
`if (hasAdmin)`; sweep the `GetRequiredService` sites found above. Add a boot log line on a driver-only
node: "driver-only node — no ConfigDb registered; config via ConfigSource:Mode=FetchAndCache".
**Step 4:** Run the test + `dotnet build` — confirm pass and no unresolved-service throw.
**Step 5: Commit** — `feat(mesh-phase4): register ConfigDb only on admin-role nodes`.
---
## Task 1b: driver-only `ILdapGroupRoleMappingService` — empty (appsettings-only group→role) — DISCOVERED during Task 1
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/NullLdapGroupRoleMappingService.cs` (or a Host-side
driver-only impl — place it beside `OtOpcUaGroupRoleMapper`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs:317-327` (the `hasDriver` auth block — register the
empty service with `TryAddScoped` so a fused node keeps the real EF one; fix the now-false comment at
322-323)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` (or wherever `OtOpcUaGroupRoleMapper` is tested)
**Context — a 6th ConfigDb consumer the §6.1 audit missed.** `OtOpcUaGroupRoleMapper` (the OPC UA
data-path group→role mapper, `IGroupRoleMapper<string>`, registered at `Program.cs:327`) merges the
appsettings `Security:Ldap:GroupToRole` baseline (always available) with **system-wide DB grants** from
`ILdapGroupRoleMappingService` — which is registered **only** inside `AddOtOpcUaConfigDb`. Task 1 gated
that on `hasAdmin`, so a driver-only node now cannot resolve `IGroupRoleMapper<string>` at OPC UA login
(lazy scoped resolve — `Build()` does not surface it; the auth path does). `LdapGroupRoleMapping` rows are
**not** in the deployment artifact (`ConfigComposer` never composes them), so driver nodes never received
DB grants via config in the first place. The honest fix: a driver-only `ILdapGroupRoleMappingService` whose
`GetByGroupsAsync` returns empty (CRUD methods throw `NotSupportedException` — never called with no AdminUI),
registered via `TryAddScoped` in the `hasDriver` block so the fused node's real EF impl still wins.
**Behavior reduction to document (Task 8):** DB-backed LDAP group→role grants (the AdminUI RoleGrants page)
apply only where ConfigDb is present (admin/fused nodes); driver-only site nodes map groups→roles from
`Security:Ldap:GroupToRole` appsettings only.
**Step 1: Write the failing test** — resolve `IGroupRoleMapper<string>` in a driver-only service graph and
call `MapAsync(["some-group"])` → returns the appsettings baseline with no throw (today it throws:
`ILdapGroupRoleMappingService` unresolvable).
**Step 2:** Run — fails.
**Step 3: Implement** the empty service + `TryAddScoped` registration in the `hasDriver` block; correct the
`Program.cs:322-323` comment to state the driver-only node uses the empty impl and why.
**Step 4:** Run tests + build.
**Step 5: Commit** — `fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants)`.
---
## Task 2: `DbHealthProbeActor` — do not spawn on driver-only; `DbReachable=true` constant
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ServiceLevel; Task 3 depends on its message contract)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:329-332` (spawn site)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:641-655`
(the `_dbHealthProbe is null` seam + the `Stale` derivation)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorServiceLevelTests.cs`
(extend the existing ServiceLevel test file)
**Context:** `ServiceLevelCalculator.Compute` collapses `(DbReachable=false, probe ok, not stale)` → 0.
The retired-input rule (decision 3): on a node with `_dbHealthProbe is null`, feed
`DbReachable: true` and derive `Stale` **without** the DB-health term —
`Stale = (now - snapshotEntry.AsOfUtc) > _staleWindow || runningBehindConfig`. Do NOT fall through to
`LegacyRoleOnly` for driver-only nodes: that seam exists for the pre-first-sample bootstrap window on a
DB-backed node, and would peg a healthy site node at the role-only mapping forever. Introduce an explicit
"DB-less node" branch that computes the full `NodeHealthInputs` with `DbReachable=true` and the DB-free
staleness. `runningBehindConfig` = the Phase-3 running-from-cache / last-apply-failed signal if reachable
from the publish actor; if it is not currently threaded here, use the snapshot-age term alone and record
a `TODO(Phase 4 follow-up)` — do not thread a new dependency just for this in-scope.
**Step 1: Write failing tests** — (a) DB-less node, member Up, probe ok, snapshot fresh → **240**
(+10 if Primary); (b) DB-less node, snapshot stale (>window) → **200**; (c) DB-backed node unchanged
(regression). Assert against `OpcUaPublishActor`'s computed `ServiceLevelChanged`, not the calculator
in isolation.
**Step 2:** Run — (a)/(b) fail (today a DB-less node hits `LegacyRoleOnly` or 0).
**Step 3: Implement** — in `ServiceCollectionExtensions`, spawn `DbHealthProbeActor` only when
`dbFactory is not null`; register `DbHealthProbeActorKey` only then; pass `dbHealthProbe: null` into
`OpcUaPublishActor.Props` on a DB-less node. In `OpcUaPublishActor`, add the DB-less branch described
above (guard on "no probe wired AND this is a driver-only/DB-less node" — a clean signal is
`_dbHealthProbe is null` combined with a ctor flag `dbLess`, threaded from the spawn site, so the
legacy-bootstrap seam stays reachable only on DB-backed nodes before their first sample).
**Step 4:** Run tests + build — confirm pass.
**Step 5: Commit** — `feat(mesh-phase4): retire DB-reachability from ServiceLevel on DB-less nodes`.
---
## Task 3: `OpcUaPublishActor` — nullable factory; driver-only rebuild always carries bytes
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (same file as Task 2 — sequence after it)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs`
**Context:** `_dbFactory` is already nullable on this actor; `HandleRebuild`'s `_dbFactory is null ||
_applier is null` guard falls back to a raw `_sink.RebuildAddressSpace()`. That fallback is the
**wrong** path for a driver-only node that HAS an applier but no factory — it would skip the
diff-and-apply. The correct driver-only invariant: `_applier` is wired, `_dbFactory` is null, and every
`RebuildAddressSpace` carries `msg.Artifact` in-hand (Phase-3 `DriverHostActor` already does this). Split
the guard so a null factory **with** an applier still does the diff-and-apply pass, using `msg.Artifact`,
and asserts (logs an Error + abandons per #485) if `msg.Artifact` is null on such a node — a null artifact
with no factory to fall back to is "no answer," never an empty rebuild.
**Step 1: Write failing tests** — (a) applier wired, factory null, `RebuildAddressSpace` **with**
`Artifact` → diff-and-apply runs (assert applier invoked, not the raw-sink fallback); (b) applier wired,
factory null, `RebuildAddressSpace` with **null** Artifact → no rebuild, Error logged (#485 negative).
**Step 2:** Run — (a) fails (null factory currently routes to the raw-sink fallback).
**Step 3: Implement** — restructure `HandleRebuild`: `if (_applier is null) { raw-sink fallback; return; }`
then the diff-and-apply block, where `artifact = msg.Artifact ?? (_dbFactory is not null ? (depId path
else latest) : Array.Empty<byte>())`; the existing #485 zero-length guard then abandons the null-factory
+ null-artifact case cleanly.
**Step 4:** Run tests + build.
**Step 5: Commit** — `feat(mesh-phase4): OpcUaPublish diff-applies from in-hand bytes with no ConfigDb`.
---
## Task 4: `DriverHostActor` — nullable factory; drop redundant SQL ack-writes on DB-less nodes
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (core actor; Tasks 56 build the store it constructs)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
(`_dbFactory` field/ctor/Props → nullable; the ~9 `UpsertNodeDeploymentState` call sites;
the bootstrap `NodeDeploymentState` read at 606-612; the `EfAlarmConditionStateStore` ctor at 588;
the CreateDbContext sites at 1920, 2096, 2588)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:455` (Props call)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDbLessTests.cs` (create)
**Context:** Decision 4 — central's `ConfigPublishCoordinator.PersistNodeAck` is the ack system of record.
On a DB-less node `UpsertNodeDeploymentState` has no ConfigDb to write and its row is written by central
anyway. Guard every `UpsertNodeDeploymentState` body on `_dbFactory is not null` (make the method a no-op
when null) rather than deleting call sites — keeps the fused-node behaviour identical and the diff small.
Same for the bootstrap read (606-612) and the Direct-only CreateDbContext sites (1920/2096/2588): each is
already unreachable on a `FetchAndCache` node (Phase 3 routes bootstrap through the LocalDb pointer and
apply through the fetcher), so the change is defensive nullability, not new control flow. Verify each of
1920/2096/2588 is inside a Direct-mode-only path before guarding — if any is on the shared apply path,
that is a Phase-3 gap to surface, not silently guard.
**Two specific sites the Task-2 code review surfaced (MUST be handled here):**
- **Remove the interim `dbFactory!` at `ServiceCollectionExtensions.cs:~470`** (added in Task 2 to preserve
compilation) — once `DriverHostActor._dbFactory` is nullable, pass the plain (nullable) `dbFactory`.
- **`UpsertNodeDeploymentState` (2613-2643) is called UNCONDITIONALLY on the FetchAndCache apply path**
(`BeginFetchAndCacheApply`/`HandleFetchedForApply`, ~1716/1723/1774/1785/1801). Its try/catch swallows the
null-factory NRE and logs a misleading "transient DB" warning on every deploy on a driver-only node. The
`_dbFactory is not null` guard fixes this.
- **`SpawnScriptedAlarmHost` (called unconditionally from PreStart ~524) constructs
`new EfAlarmConditionStateStore(_dbFactory, ...)` at ~587-588** — with a null factory every alarm
Load/Save NREs (swallowed → scripted-alarm state silently dead on driver-only nodes). **This is fixed by
Task 6** (which swaps in the LocalDb-backed store), NOT by a null guard — do NOT leave the Ef store
constructed with a null factory. If Task 6 lands after this task, add a temporary `_dbFactory is not null`
guard around the Ef-store construction so a DB-less node skips it cleanly until Task 6 wires the LocalDb
store; the two tasks together must leave a DB-less node with a WORKING (LocalDb) condition store, not a
skipped one.
**Step 1: Write failing tests** — construct a `DriverHostActor` with `dbFactory: null`,
`fetchAndCacheMode: true`, a fake fetcher + LocalDb cache (mirror `DriverHostActorFetchAndCacheTests`):
(a) a full dispatch → Applied ack, **no** NullReferenceException from a factory deref; (b) bootstrap
with a seeded LocalDb pointer → revision restored with no ConfigDb read.
**Step 2:** Run — fails today (`_dbFactory` non-nullable; `UpsertNodeDeploymentState` derefs it).
**Step 3: Implement** — make `_dbFactory` nullable through field/ctor/both `Props` overloads (nullable
param **last**, matching the Phase-3 additions); `UpsertNodeDeploymentState` early-returns when null (log
Debug once); guard the read sites. Update `ServiceCollectionExtensions:455` to pass the (already nullable)
`dbFactory`.
**Step 4:** Run tests + build.
**Step 5: Commit** — `feat(mesh-phase4): DriverHostActor runs with no ConfigDb (central persists acks)`.
---
## Task 5: `alarm_condition_state` LocalDb table + `LocalDbAlarmConditionStateStore`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs`
- Modify: the LocalDb schema/DDL + `RegisterReplicated` wiring (find via
`grep -rn "alarm_sf_events" src` — mirror that table's registration exactly, in the same
`LocalDbSetup.OnReady` DDL→`RegisterReplicated` order which is load-bearing)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs`
(port `EfAlarmConditionStateStoreTests` — same `IAlarmStateStore` contract, so the assertion bodies
transfer verbatim; only the store construction changes)
**Context:** `LocalDbAlarmConditionStateStore : IAlarmStateStore` must round-trip the same fields
`EfAlarmConditionStateStore` does (Enabled/Acked/Confirmed/Shelving + ack/confirm audit + `CommentsJson`;
Active/LastActive/LastCleared transient; `LastTransitionUtc ↔ UpdatedAtUtc`). Columns mirror
`ScriptedAlarmState`. Replicated like `alarm_sf_events` so a failover peer holds the state — but **unlike**
the S&F buffer there is no drain gate: condition state is not delivered-once, it is the current snapshot,
idempotent last-write-wins on `UpdatedAtUtc` (the Ef store already tolerates the write race that way).
**Do NOT stack app-level LWW on LocalDb's HLC** (design §8) — the row upsert keyed by `ScriptedAlarmId`
is a single unconditional write, which is HLC-safe; keep it that way.
**Step 1: Write failing tests** — the ported `IAlarmStateStore` round-trip suite against the LocalDb
store over a temp SQLite DB (the Runtime.Tests harness already opens LocalDb elsewhere — reuse it).
**Step 2:** Run — fails (store + table absent).
**Step 3: Implement** — the store + the `alarm_condition_state` DDL + `RegisterReplicated` registration.
**Step 4:** Run tests + build.
**Step 5: Commit** — `feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)`.
---
## Task 6: Wire the LocalDb condition store into `DriverHostActor` for driver-role nodes
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 5's type + Task 4's nullable factory)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:588`
(construct `LocalDbAlarmConditionStateStore` instead of `EfAlarmConditionStateStore`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs`
(resolve the LocalDb store dependency; thread it into `DriverHostActor.Props`)
- Test: extend `DriverHostActorDbLessTests` — the ScriptedAlarm host constructs against LocalDb with a
null ConfigDb factory.
**Context:** The store is constructed inside `DriverHostActor.BuildScriptedAlarmHost` (line ~588). It
takes the LocalDb connection the node already opened (Phase 1/2). Prefer resolving an
`IAlarmStateStore` from DI (registered in the Host's `hasDriver` LocalDb block) over `new`-ing it in the
actor, so the actor stays factory-agnostic. Confirm the registration lands in the same `AddOtOpcUaLocalDb`
`hasDriver` branch as the S&F sink.
**Step 1: Write failing test** — DB-less `DriverHostActor` boots its ScriptedAlarm host and a condition
save/load round-trips through LocalDb (no ConfigDb).
**Step 2:** Run — fails (still constructs the Ef store needing a factory).
**Step 3: Implement** — register `IAlarmStateStore → LocalDbAlarmConditionStateStore` in the Host
`hasDriver` LocalDb block; resolve + thread it; delete the `EfAlarmConditionStateStore` construction from
the actor. Leave the `EfAlarmConditionStateStore` class in the tree (removed in Task 9's migration cleanup)
or delete it now if nothing else references it (grep first).
**Step 4:** Run tests + build.
**Step 5: Commit** — `feat(mesh-phase4): driver-role nodes store alarm condition state in LocalDb`.
---
## Task 7: Registration cleanup + full-graph sweep
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (integration seam over everything above)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` (final nullable threading)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (any remaining driver-branch ConfigDb resolves)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs`
**Context:** The exit-gate proof. After Tasks 16, sweep for any service the **driver-only** graph
resolves that still needs `OtOpcUaConfigDbContext`. Grep both projects for
`OtOpcUaConfigDbContext`, `IDbContextFactory<OtOpcUaConfigDbContext>`, `AddOtOpcUaConfigDb`,
`ScriptedAlarmStates`, and `GetRequiredService` of any of them; each hit must be `hasAdmin`-gated or
nullable-tolerant. `LdapGroupRoleMappingService` / `ILdapGroupRoleMappingService` (registered inside
`AddOtOpcUaConfigDb`) is admin/auth-side — confirm the driver-only login path does not resolve it, or
that the driver-only node never serves login (it hosts no AdminUI — see the topology table).
**Step 1: Write the failing test** — a "driver-only DI graph resolves nothing ConfigDb-bound" test:
build the `hasDriver && !hasAdmin` service provider and assert
`GetService<IDbContextFactory<OtOpcUaConfigDbContext>>()` is null AND that spawning the runtime actors
does not throw. **Also pin the Task-1b fused-node ordering** (Task 1b review gap): build the
`hasAdmin && hasDriver` graph through the same `AddOtOpcUaConfigDb` + `hasDriver` TryAdd sequence and assert
`GetService<ILdapGroupRoleMappingService>()` resolves the **real** `LdapGroupRoleMappingService`, not the
`Null…` — so a later reorder of the role blocks (or switching `AddOtOpcUaConfigDb` to a TryAdd) fails a
red test instead of silently stripping DB-backed role grants from central. Also resolve
`IGroupRoleMapper<string>` in a driver-only scope to catch the lazy auth-path break Task 1b fixed.
**Step 2:** Run — fix whatever it catches.
**Step 3: Implement** the remaining guards.
**Step 4:** Run the full Runtime.Tests + Host.IntegrationTests suites + `dotnet build`.
**Step 5: Commit** — `feat(mesh-phase4): driver-only DI graph resolves nothing ConfigDb-bound`.
---
## Task 8: Docs — Redundancy.md (ServiceLevel change) + interop note + Configuration.md + CLAUDE.md
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 9
**Files:**
- Modify: `docs/Redundancy.md` (ServiceLevel: DB-less node table + the `DbReachable=true` constant + the
240-with-central-down behaviour; the `DbHealthProbeActor` row becomes admin-only)
- Modify: `docs/Configuration.md` (driver-only nodes: no `ConfigDb` string; `ConfigSource:Mode` must be
`FetchAndCache`; validator rule)
- Modify: `CLAUDE.md` (extend the mesh section — Phase 4: driver ConfigDb cut; ServiceLevel semantics;
alarm condition state in LocalDb)
- Modify: `docs/plans/2026-07-22-per-cluster-mesh-program.md` (Phase 4 status + tracking row)
**Step 14:** Prose only — state the client-visible ServiceLevel change explicitly (a healthy DB-less
node now publishes 240/250 with central unreachable, where a Direct node would have dropped to 0/100).
**Step 5: Commit** — `docs(mesh-phase4): ServiceLevel semantics + driver ConfigDb cut`.
---
## Task 9: Drop the dead ConfigDb `ScriptedAlarmState` table (migration) + retire `EfAlarmConditionStateStore`
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (after Task 6 proves LocalDb store is the live path)
**Files:**
- Create: EF migration dropping `ScriptedAlarmState` (find the migrations dir via
`grep -rl "class OtOpcUaConfigDbContext" ` → its `Migrations/` sibling; use `dotnet ef migrations add
DropScriptedAlarmState -c OtOpcUaConfigDbContext`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs`,
`.../Entities/ScriptedAlarmState.cs` (remove the DbSet + entity)
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs` +
its test (if nothing else references them — grep first)
**Context:** Only do the delete/drop once Task 6 is green and grep confirms no remaining reference. If the
migration surface is risky (central deployments with existing rows), the drop can be deferred to a
follow-up — mark it clearly and stop, rather than shipping a risky migration under time pressure. Keeping
a dead-but-harmless table is an acceptable interim.
**Step 14:** Add migration, remove entity, delete dead store, `dotnet build` + migration-applies check
against a scratch DB.
**Step 5: Commit** — `refactor(mesh-phase4): drop dead ConfigDb ScriptedAlarmState table`.
---
## Task 10: Rig config — driver-only site nodes lose their ConfigDb string
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 8
**Files:**
- Modify: `docker-dev/docker-compose.yml` (site-a-1/2, site-b-1/2: remove
`ConnectionStrings__ConfigDb`; set `ConfigSource__Mode: "FetchAndCache"` as the **default**, not
behind `OTOPCUA_CONFIG_MODE` — Phase 4 makes Direct invalid for these nodes; central-1/2 keep ConfigDb
+ `Direct`)
**Context:** The rig currently defaults site nodes to `Direct` and flips to `FetchAndCache` via
`OTOPCUA_CONFIG_MODE`. Phase 4 makes `FetchAndCache` the only valid mode for driver-only site nodes, so
it becomes their committed default and the `ConfigDb` string is removed to prove the cut. Verify each site
service still boots (the validator from Task 0 now *requires* this shape). This is a rig change only — the
live gate (below) runs it.
**Step 14:** Edit compose; `lmxopcua-fix`-equivalent local `docker compose up`; confirm the four site
nodes boot with no ConfigDb string.
**Step 5: Commit** — `chore(mesh-phase4): rig site nodes run with no ConfigDb string`.
---
## Task 11: Live gate
**Classification:** high-risk
**Estimated implement time:** live verification (not a code task)
**Parallelizable with:** none (final)
**Files:**
- Create: `docs/plans/2026-07-23-mesh-phase4-live-gate.md` (record the run, as Phases 13 did)
**Exit gate (design §6.1 + program Phase 4):**
1. Bring up the rig (central pair `Direct` + ConfigDb; four site nodes `FetchAndCache`, **no** ConfigDb
string). All six healthy.
2. **Deploy** a config via `POST :9200/api/deployments` (`X-Api-Key: docker-dev-deploy-key`,
`Content-Type: application/json`); confirm it seals green with the site nodes acking — proving central
persists the site-node acks with the site nodes holding no ConfigDb.
3. **Alarm cycle** — drive a scripted alarm ack/shelve on a site node; confirm the condition state
round-trips through LocalDb (inspect `alarm_condition_state` via the docker-cp SQLite recipe from the
Phase-1 follow-ups memory) and replicates to its pair peer.
4. **Historian** — confirm the alarm S&F drain + (if wired) continuous historization still run on the
Primary site node with no ConfigDb.
5. **ServiceLevel** — with **central SQL stopped**, a healthy site node still publishes **240/250**
(Client.CLI `redundancy`), proving the retired DB-reachability input (a Direct node would have shown
0/100). Restart a site node → it boots last-known-good from the LocalDb pointer, no ConfigDb read.
6. **Grep proof**`grep -rn "GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>\|AddOtOpcUaConfigDb"`
shows every hit `hasAdmin`-gated; a driver-only container's env has no `ConnectionStrings__ConfigDb`.
Record findings verbatim (the Phase 13 gates each caught a defect offline tests missed — expect the
same and do not close the gate on a green-by-luck run).
---
## Batching for execution
- **Batch 1 (mechanics):** Task 0, Task 1 — options rule + registration gate.
- **Batch 2 (ServiceLevel):** Task 2, Task 3 — the client-visible change (sequential, same file).
- **Batch 3 (actor + store):** Task 4, Task 5 (‖), then Task 6.
- **Batch 4 (integration):** Task 7 — the sweep + exit-gate DI proof.
- **Batch 5 (finish):** Task 8 (‖ Task 10), Task 9, then Task 11 live gate.
@@ -0,0 +1,132 @@
{
"planPath": "docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md",
"tasks": [
{
"id": 0,
"subject": "Task 0: ConfigSourceOptionsValidator \u2014 driver-only \u21d2 FetchAndCache",
"status": "completed",
"commit": "d630a7e",
"result": "13/13 tests; APPROVED by code review"
},
{
"id": 1,
"subject": "Task 1: Program.cs \u2014 gate ConfigDb registration + health check on hasAdmin",
"status": "completed",
"commit": "a41582ea",
"result": "8/8 tests; surfaced Task 1b (IGroupRoleMapper ConfigDb dep)"
},
{
"id": "1b",
"subject": "Task 1b: driver-only ILdapGroupRoleMappingService \u2014 empty (appsettings-only)",
"status": "completed",
"blockedBy": [
1
],
"commit": "833e45db",
"result": "9/9 tests; APPROVED (Task 7 to pin fused-node ordering)"
},
{
"id": 2,
"subject": "Task 2: DbHealthProbeActor \u2014 no spawn on driver-only; DbReachable=true constant",
"status": "completed",
"blockedBy": [
1
],
"commit": "9565827",
"result": "43 tests; spec-compliant + code-review (comment fixed to honest interim; functional fix = Task 4/6)"
},
{
"id": 3,
"subject": "Task 3: OpcUaPublishActor \u2014 nullable factory; rebuild always carries bytes",
"status": "completed",
"blockedBy": [
2
],
"commit": "73ae8ec5",
"result": "44 tests (1+2 RED pre-fix); SPEC-COMPLIANT + APPROVED. Wipe prevented at every entry point; validator is belt-and-suspenders"
},
{
"id": 4,
"subject": "Task 4: DriverHostActor \u2014 nullable factory; drop redundant SQL ack-writes",
"status": "completed",
"blockedBy": [
1
],
"commit": "d9071607",
"result": "Tasks 4+6 combined (avoid churn). 32+162+6 tests; SPEC-COMPLIANT + APPROVED. Review re-confirmed Task 3 is a HARD PREREQ before any FetchAndCache flip (address-space wipe)"
},
{
"id": 5,
"subject": "Task 5: alarm_condition_state LocalDb table + LocalDbAlarmConditionStateStore",
"status": "completed",
"commit": "4f4cdd05",
"result": "16 new tests; SPEC-COMPLIANT + APPROVED (ON CONFLICT DO UPDATE replication-safe). Fold Kind+null-audit test polish into Task 4/6"
},
{
"id": 6,
"subject": "Task 6: Wire LocalDb condition store into DriverHostActor for driver-role",
"status": "completed",
"blockedBy": [
4,
5
],
"commit": "d9071607",
"result": "Tasks 4+6 combined (avoid churn). 32+162+6 tests; SPEC-COMPLIANT + APPROVED. Review re-confirmed Task 3 is a HARD PREREQ before any FetchAndCache flip (address-space wipe)"
},
{
"id": 7,
"subject": "Task 7: Registration cleanup + full-graph driver-only DI sweep",
"status": "completed",
"blockedBy": [
"1b",
2,
3,
4,
6
],
"commit": "6f21213f",
"result": "12/12; sweep verified clean; ordering+lazy-resolve pins APPROVED (red-check confirmed load-bearing)"
},
{
"id": 8,
"subject": "Task 8: Docs \u2014 Redundancy.md ServiceLevel + interop + Configuration.md + CLAUDE.md",
"status": "completed",
"blockedBy": [
7
],
"commit": "55a6e1f2",
"result": "Redundancy.md (DB-less ServiceLevel) + Configuration.md + CLAUDE.md + program doc (Phase 4 status + stale Phase 3 fix); all claims code-verified"
},
{
"id": 9,
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
"status": "deferred",
"blockedBy": [
6
],
"result": "DEFERRED to follow-up (plan-sanctioned): EF store is inert on all real nodes (test-harness fallback only); table-drop migration is irreversible. Not needed for the ConfigDb-cut goal. Tracked in program doc."
},
{
"id": 10,
"subject": "Task 10: Rig \u2014 driver-only site nodes lose ConfigDb string",
"status": "completed",
"blockedBy": [
7
],
"commit": "HEAD",
"result": "4 site nodes: no ConfigDb string + FetchAndCache hardcoded (Direct now validator-rejected); central keeps ConfigDb+Direct+ConfigServe; YAML validated"
},
{
"id": 11,
"subject": "Task 11: Live gate",
"status": "completed",
"blockedBy": [
8,
9,
10
],
"result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md"
}
],
"lastUpdated": "2026-07-23"
}
@@ -0,0 +1,91 @@
# Per-Cluster Mesh Phase 4 — Live Gate Record
**Date:** 2026-07-23
**Branch:** `feat/mesh-phase4` (image built + rig recreated with Phase-4 code)
**Rig:** local `docker-dev` — central-1/2 (`admin,driver`, Direct + ConfigDb + ConfigServe), four site
nodes (`driver`, **no ConfigDb string**, `ConfigSource:Mode=FetchAndCache`). Deployment `b390f535`.
**Result: PASSED.** Every load-bearing exit-gate leg proven live. A driver-only node runs a full deploy
+ boot-from-cache cycle with **no ConfigDb connection string configured at all**, and the client-visible
ServiceLevel change behaves as designed.
## What was verified
### Leg 0 — boot ConfigDb-free (headline)
Every site node booted with no ConfigDb string and logged, e.g.:
```
DriverHost site-a-1:4053: FetchAndCache boot — restored served state for deployment 1841b0c2…
(rev 7a7d548e…) from the LocalDb pointer.
```
All six nodes came up clean (no FATAL / startup exception). The transient `RpcException Unavailable`
(central ConfigServe not yet up when the first fetch fired) and the `Name or service not known`
(the rig's deliberately-unresolvable historian endpoint) are expected startup noise — the node fell back
to the LocalDb pointer (#485-safe), never to an empty address space.
### Leg 1 — deploy seals green with site nodes acking, no ConfigDb ✅
`POST /api/deployments` (`X-Api-Key: docker-dev-deploy-key`) → `202 Accepted` (deployment `b390f535`).
It **sealed** (`Deployment.Status = 2`) at 17:57:33. Per-node `NodeDeploymentState`:
| Node | Status |
|---|---|
| central-1:4053 | 1 (Applied) |
| central-2:4053 | 1 (Applied) |
| **site-a-1:4053** | **1 (Applied)** |
| **site-a-2:4053** | **1 (Applied)** |
| **site-b-1:4053** | **1 (Applied)** |
| **site-b-2:4053** | **1 (Applied)** |
The four site nodes hold no ConfigDb — those ack rows were written by central's
`ConfigPublishCoordinator.PersistNodeAck` from the `ApplyAck` each site node sent over the transport.
Full deploy→fetch→apply→ack cycle proven ConfigDb-free.
### Leg 2 — ServiceLevel with central SQL DOWN (client-visible change) ✅
- Baseline (SQL up): site-a-1 (`opc.tcp://localhost:4842`) **ServiceLevel = 240** (healthy follower).
- `docker stop otopcua-dev-sql-1` → site-a-1 **ServiceLevel = 240, unchanged**.
A DB-less node feeds `DbReachable = true` constant with staleness from the redundancy-snapshot age only,
so it stays at full service when central SQL is unreachable — the survive-alone posture. A Direct/DB-backed
node would have dropped to 0/100 (its `DbHealthProbe` trips). Confirmed live: central-1's own probe went
`Reachable = False` while SQL was down (and back to `True` when it returned), while the site node never moved.
### Leg 3 — restart a site node with central SQL DOWN → boots last-known-good ✅
Restarted site-b-1 (the non-replicating site-b pin) while SQL was still stopped. Post-restart boot
(18:00:5859):
```
Cluster Node [akka.tcp://otopcua@site-b-1:4053] - Started up successfully
OPC UA server started on opc.tcp://0.0.0.0:4840
DriverHost site-b-1:4053: FetchAndCache boot — restored served state for deployment 1841b0c2…
(rev 7a7d548e…) from the LocalDb pointer.
```
No ConfigDb / SQL error in the fresh boot (it never dials SQL). Post-restart **ServiceLevel = 240** — it
served last-known-good from the LocalDb pointer with central SQL down.
### Leg 4 — grep proof ✅
Each site container's env: `ConnectionStrings__ConfigDb` count = **0**, `ConfigSource__Mode=FetchAndCache`.
Central-1 (contrast): carries `ConnectionStrings__ConfigDb` + `Mode=Direct`. Code sweep (Task 7) confirmed
no driver-branch service resolves `OtOpcUaConfigDbContext`.
### Leg 5 — alarm condition state in LocalDb ✅ (table live + replicated)
site-a-1's consolidated LocalDb (`/app/data/otopcua-localdb.db`) carries the `alarm_condition_state`
table with its replication capture triggers (`__localdb_*`), alongside `deployment_artifacts`,
`deployment_pointer`, and `alarm_sf_events`. 0 rows (no scripted alarm has fired in this window). The
store's save/load round-trip is unit-tested (`LocalDbAlarmConditionStateStoreTests`); a full operator
ack→row cycle was not driven live (needs a configured scripted alarm + a triggering value) — noted as the
one leg proven by test + schema-liveness rather than an end-to-end row write.
## Cleanup
`docker start otopcua-dev-sql-1` → healthy; all six nodes up; central-1 DbHealth recovered
(`Reachable=False → True` at 18:01:43). Rig healed.
## Diagnostics note (the "stuck gate")
The initial rig rebuild appeared hung: an abandoned `dotnet-dump analyze hang.dmp` (PID 94724, ~115 h of
CPU since Saturday) had pinned a core, starving the emulated `linux/amd64` `dotnet publish` (the Phase-3
protoc-segfault build-stage pin), and `docker compose --build`'s default `rawjson` progress streamed
nothing to stdout. Killing the runaway process + rebuilding with `--progress=plain` made the build both
faster and observable. No Phase-4 defect involved.
## Verdict
**Phase 4 live gate PASSED.** Driver-only nodes run ConfigDb-free end to end; the ServiceLevel semantics
change is live and correct. Remaining Phase-4 item: Task 9 (drop the dead ConfigDb `ScriptedAlarmState`
table + retire `EfAlarmConditionStateStore`) — deferred as plan-sanctioned (inert on all real nodes;
irreversible migration).
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster; namespace ZB.MOM.WW.OtOpcUa.Cluster;
@@ -14,6 +15,20 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// </remarks> /// </remarks>
public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSourceOptions> public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSourceOptions>
{ {
private readonly IConfiguration _configuration;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. <see cref="IConfiguration"/> — not
/// <c>IClusterRoleInfo</c> — is the source of this node's roles: <c>IClusterRoleInfo</c>'s
/// implementation needs the <c>ActorSystem</c>, which does not exist yet at
/// <c>ValidateOnStart</c> time.
/// </summary>
public ConfigSourceOptionsValidator(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc /> /// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options) public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
{ {
@@ -76,6 +91,24 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
} }
} }
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
// connection). Roles come from Cluster:Roles directly, not IClusterRoleInfo — see the ctor
// remark.
var roles = _configuration.GetSection("Cluster:Roles").Get<string[]>() ?? Array.Empty<string>();
var isDriver = Array.IndexOf(roles, "driver") >= 0;
var isAdmin = Array.IndexOf(roles, "admin") >= 0;
if (isDriver && !isAdmin && isDirect)
{
errors.Add(
"Cluster:Roles is a driver-only node (has 'driver', not 'admin') but "
+ $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)} is 'Direct'. A "
+ "driver-only node has no central ConfigDb to read from — it must use 'FetchAndCache'. "
+ $"Set {ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)}="
+ $"{ConfigSourceOptions.ModeFetchAndCache} (see docs/Configuration.md → ConfigSource).");
}
return errors.Count == 0 return errors.Count == 0
? ValidateOptionsResult.Success ? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(string.Join(" ", errors)); : ValidateOptionsResult.Fail(string.Join(" ", errors));
@@ -1,65 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Persistent runtime state for each scripted alarm. Survives process restart so
/// operators don't re-ack and ack history survives for
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
/// per-generation) because ack state follows the alarm's stable identity across
/// generations — a Modified alarm keeps its ack history.
/// </summary>
/// <remarks>
/// <para>
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
/// </para>
/// <para>
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
/// serializes directly into this column.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmState
{
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Enabled/Disabled. Persists across restart.</summary>
public required string EnabledState { get; set; } = "Enabled";
/// <summary>Unacknowledged / Acknowledged.</summary>
public required string AckedState { get; set; } = "Unacknowledged";
/// <summary>Unconfirmed / Confirmed.</summary>
public required string ConfirmedState { get; set; } = "Unconfirmed";
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
public required string ShelvingState { get; set; } = "Unshelved";
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
public DateTime? ShelvingExpiresUtc { get; set; }
/// <summary>User who last acknowledged. Null if never acked.</summary>
public string? LastAckUser { get; set; }
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
public string? LastAckComment { get; set; }
/// <summary>Gets or sets the UTC timestamp of the last acknowledgment.</summary>
public DateTime? LastAckUtc { get; set; }
/// <summary>User who last confirmed.</summary>
public string? LastConfirmUser { get; set; }
/// <summary>Gets or sets the operator-supplied confirm comment. Null if no comment or never confirmed.</summary>
public string? LastConfirmComment { get; set; }
/// <summary>Gets or sets the UTC timestamp of the last confirmation.</summary>
public DateTime? LastConfirmUtc { get; set; }
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
public string CommentsJson { get; set; } = "[]";
/// <summary>Row write timestamp — tracks last state change.</summary>
public DateTime UpdatedAtUtc { get; set; }
}
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class DropScriptedAlarmStateTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ScriptedAlarmState");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ScriptedAlarmState",
columns: table => new
{
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
}
}
}
@@ -1113,74 +1113,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
}); });
}); });
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b =>
{
b.Property<string>("ScriptedAlarmId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("AckedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ConfirmedState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("EnabledState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("LastAckComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastAckUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastAckUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("LastConfirmComment")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("LastConfirmUser")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("LastConfirmUtc")
.HasColumnType("datetime2(3)");
b.Property<DateTime?>("ShelvingExpiresUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("ShelvingState")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("UpdatedAtUtc")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2(3)")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.HasKey("ScriptedAlarmId");
b.ToTable("ScriptedAlarmState", null, t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{ {
b.Property<string>("ClusterId") b.Property<string>("ClusterId")
@@ -56,8 +56,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>(); public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
/// <summary>Gets the DbSet of scripted alarms.</summary> /// <summary>Gets the DbSet of scripted alarms.</summary>
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>(); public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
/// <summary>Gets the DbSet of scripted alarm states.</summary>
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
// v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment). // v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment).
/// <summary>Gets the DbSet of deployments.</summary> /// <summary>Gets the DbSet of deployments.</summary>
@@ -98,7 +96,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureScript(modelBuilder); ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder); ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder); ConfigureScriptedAlarm(modelBuilder);
ConfigureScriptedAlarmState(modelBuilder);
ConfigureDeployment(modelBuilder); ConfigureDeployment(modelBuilder);
ConfigureNodeDeploymentState(modelBuilder); ConfigureNodeDeploymentState(modelBuilder);
ConfigureConfigEdit(modelBuilder); ConfigureConfigEdit(modelBuilder);
@@ -716,34 +713,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
}); });
} }
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarmState>(e =>
{
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
// stable identity across generations — Modified alarms keep their ack audit trail.
e.ToTable("ScriptedAlarmState", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
e.HasKey(x => x.ScriptedAlarmId);
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EnabledState).HasMaxLength(16);
e.Property(x => x.AckedState).HasMaxLength(16);
e.Property(x => x.ConfirmedState).HasMaxLength(16);
e.Property(x => x.ShelvingState).HasMaxLength(16);
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastAckUser).HasMaxLength(128);
e.Property(x => x.LastAckComment).HasMaxLength(1024);
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
});
}
private static void ConfigureDeployment(ModelBuilder modelBuilder) private static void ConfigureDeployment(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Deployment>(e => modelBuilder.Entity<Deployment>(e =>
@@ -0,0 +1,82 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
/// trail and comment history.
/// </summary>
/// <remarks>
/// <para>
/// Replaces the central config DB's <c>ScriptedAlarmState</c> table as the node-local home
/// for this state (per-cluster mesh Phase 4, which cuts the ConfigDb from driver-only
/// nodes). Living in the consolidated LocalDb file is what lets the state replicate to the
/// redundant pair peer, exactly like the alarm store-and-forward buffer — see
/// <see cref="AlarmSfSchema"/>, which this mirrors.
/// </para>
/// <para>
/// Deliberately depends on nothing but <see cref="SqliteConnection"/> so it can be applied
/// to any connection — the host's <c>LocalDbSetup.OnReady</c> in production, and a bare
/// connection in a test — without dragging the DI graph along.
/// </para>
/// <para>
/// <b>The primary key is the alarm identity, and Save is an unconditional upsert onto it.</b>
/// Convergence across the pair is last-writer-wins over the primary key, handled by the
/// LocalDb replication HLC — so the store stacks no application-level last-write-wins on top,
/// the same discipline the alarm-sf sink and the deployment pointer follow.
/// </para>
/// <para>
/// <b>ActiveState has no column</b> — it is re-derived from the live predicate on startup,
/// so persisting it would only risk operators seeing a stale Active on restart. Likewise
/// <c>LastActiveUtc</c> / <c>LastClearedUtc</c> re-derive alongside it and get no columns.
/// <c>LastTransitionUtc</c> is carried by <c>updated_at_utc</c> — the row-write timestamp is
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
/// chronological; nullable timestamps and audit fields are stored as NULL.
/// </para>
/// </remarks>
public static class AlarmConditionStateSchema
{
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
public const string StateTable = "alarm_condition_state";
/// <summary>
/// Creates the state table if it does not already exist. Idempotent.
/// </summary>
/// <param name="connection">
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
/// pragma-configured connections — do not call <c>Open()</c> on one.
/// </param>
public static void Apply(SqliteConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
using var cmd = connection.CreateCommand();
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
// transition column).
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS alarm_condition_state (
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
enabled_state TEXT NOT NULL,
acked_state TEXT NOT NULL,
confirmed_state TEXT NOT NULL,
shelving_state TEXT NOT NULL,
shelving_expires_utc TEXT NULL,
last_ack_user TEXT NULL,
last_ack_comment TEXT NULL,
last_ack_utc TEXT NULL,
last_confirm_user TEXT NULL,
last_confirm_comment TEXT NULL,
last_confirm_utc TEXT NULL,
comments_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
}
@@ -2,7 +2,9 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
@@ -96,6 +98,13 @@ public static class LocalDbRegistration
// replicating, and never written to. // replicating, and never written to.
services.AddSingleton<IDeploymentArtifactCache, LocalDbDeploymentArtifactCache>(); services.AddSingleton<IDeploymentArtifactCache, LocalDbDeploymentArtifactCache>();
// Per-cluster mesh Phase 4: scripted-alarm condition state served from the replicated LocalDb
// store instead of the ConfigDb-backed EF store, so a driver-only node (no ConfigDb) keeps its
// Part 9 condition state and mirrors it to its pair peer. Driver-role only, alongside the cache —
// admin-only nodes never register LocalDb. WithOtOpcUaRuntimeActors resolves it optionally and
// threads it into DriverHostActor's ScriptedAlarm engine. Singleton to match ILocalDb + the cache.
services.AddSingleton<IAlarmStateStore, LocalDbAlarmConditionStateStore>();
return services; return services;
} }
} }
@@ -6,8 +6,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary> /// <summary>
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache and /// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
/// alarm store-and-forward tables and opts them into replication. /// alarm store-and-forward, and scripted-alarm condition-state tables and opts them into
/// replication.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -56,11 +57,13 @@ public static class LocalDbSetup
{ {
DeploymentCacheSchema.Apply(connection); DeploymentCacheSchema.Apply(connection);
AlarmSfSchema.Apply(connection); AlarmSfSchema.Apply(connection);
AlarmConditionStateSchema.Apply(connection);
} }
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable); db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
db.RegisterReplicated(DeploymentCacheSchema.PointerTable); db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
db.RegisterReplicated(AlarmSfSchema.EventsTable); db.RegisterReplicated(AlarmSfSchema.EventsTable);
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
// LAST, and only here. This is the one call in OnReady that writes rows. // LAST, and only here. This is the one call in OnReady that writes rows.
AlarmSfLegacyMigrator.Migrate(db, configuration); AlarmSfLegacyMigrator.Migrate(db, configuration);
@@ -17,21 +17,37 @@ public static class HealthEndpoints
{ {
/// <summary> /// <summary>
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on /// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
/// ready+active; admin-leader on active only. /// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh
/// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff <paramref name="hasAdmin"/>.
/// </summary> /// </summary>
/// <param name="services">The service collection to register the health checks on.</param> /// <param name="services">The service collection to register the health checks on.</param>
/// <param name="hasAdmin">
/// Whether this node carries the <c>admin</c> role. When <c>false</c> (a driver-only node) the
/// <c>configdb</c> probe is skipped — the node registers no <see cref="OtOpcUaConfigDbContext"/>
/// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to
/// <c>true</c> so admin/fused callers (including the test harnesses) keep the probe unchanged.
/// </param>
/// <returns>The same service collection, for chaining.</returns> /// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services) public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true)
{ {
services.AddHealthChecks() var checks = services.AddHealthChecks();
.AddTypeActivatedCheck<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
// configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config
// from central via ConfigSource:Mode=FetchAndCache and never registers the ConfigDb factory, so
// the type-activated DatabaseHealthCheck would throw resolving it. Admin + fused nodes: unchanged.
if (hasAdmin)
{
checks.AddTypeActivatedCheck<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
"configdb", "configdb",
failureStatus: null, failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active }, tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext> args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
{ {
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct), ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
}) });
}
checks
.AddTypeActivatedCheck<AkkaClusterHealthCheck>( .AddTypeActivatedCheck<AkkaClusterHealthCheck>(
"akka", "akka",
failureStatus: null, failureStatus: null,
@@ -42,11 +58,10 @@ public static class HealthEndpoints
failureStatus: null, failureStatus: null,
tags: new[] { ZbHealthTags.Active }, tags: new[] { ZbHealthTags.Active },
args: "admin") args: "admin")
// Registered unconditionally, not driver-gated. AddOtOpcUaHealth takes no role argument // Registered on every node regardless of role (unlike the admin-only configdb probe above):
// and runs on every node; the check itself resolves ISyncStatus optionally and reports // the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
// Healthy when LocalDb is absent (admin-only graphs) or replication is default-OFF, so a // (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
// plain node is never degraded by it. A factory registration keeps this no-arg signature // A factory registration reads ISyncStatus + options + the sync port from the container.
// while still reading ISyncStatus + options + the sync port from the container.
.Add(new HealthCheckRegistration( .Add(new HealthCheckRegistration(
"localdb-replication", "localdb-replication",
sp => new LocalDbReplicationHealthCheck( sp => new LocalDbReplicationHealthCheck(
+23 -5
View File
@@ -13,6 +13,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
@@ -122,8 +123,16 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1 // Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
// rather than in-process, so the binary stays cross-platform-compilable. // rather than in-process, so the binary stays cross-platform-compilable.
// Shared services — always registered regardless of role. ConfigDb is required for everything. // ConfigDb is admin-only now (per-cluster mesh Phase 4). Only the admin role owns a ConfigDb
builder.Services.AddOtOpcUaConfigDb(builder.Configuration); // connection string; a driver-only node holds none at all and gets its deployed configuration from
// central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
else
Log.Information("Driver-only node — no ConfigDb registered; configuration via ConfigSource:Mode=FetchAndCache");
builder.Services.AddOtOpcUaCluster(builder.Configuration); builder.Services.AddOtOpcUaCluster(builder.Configuration);
// Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused) // Validate LdapOptions unconditionally so ANY role node (admin-only, driver-only, or fused)
@@ -311,11 +320,18 @@ if (hasDriver)
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the // OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper // shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
// <string> per call to turn the directory's groups into roles, so register it here for driver- // <string> per call to turn the directory's groups into roles, so register it here for driver-
// only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it // only nodes (AddOtOpcUaAuth registers it on admin nodes). OtOpcUaGroupRoleMapper also depends
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above. // on ILdapGroupRoleMappingService (the control-plane DB-grants CRUD surface) — Phase 4 gated
// AddOtOpcUaConfigDb on hasAdmin, so a driver-only node has no ConfigDb to read it from. Bind
// NullLdapGroupRoleMappingService (always "no DB grants") here via TryAdd so the mapper falls
// back to the appsettings Security:Ldap:GroupToRole baseline; on a fused admin+driver node the
// hasAdmin block above already ran AddOtOpcUaConfigDb's non-Try AddScoped<ILdapGroupRoleMapping
// Service, LdapGroupRoleMappingService>, so this TryAdd is a no-op there and the real EF service
// wins.
// Note: AddValidatedOptions<LdapOptions, LdapOptionsValidator> is now registered unconditionally // Note: AddValidatedOptions<LdapOptions, LdapOptionsValidator> is now registered unconditionally
// above both role blocks so admin-only nodes also get fail-fast LDAP startup validation. // above both role blocks so admin-only nodes also get fail-fast LDAP startup validation.
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>(); builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>(); builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>(); builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
@@ -392,7 +408,9 @@ if (hasAdmin)
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration. // Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
builder.Services.AddOtOpcUaSecrets(builder.Configuration); builder.Services.AddOtOpcUaSecrets(builder.Configuration);
builder.Services.AddOtOpcUaHealth(); // hasAdmin gates the configdb probe: a driver-only node registers no ConfigDb factory (see above), so
// probing it would throw when the readiness endpoint resolves the factory.
builder.Services.AddOtOpcUaHealth(hasAdmin);
builder.Services.AddOtOpcUaObservability(builder.Configuration); builder.Services.AddOtOpcUaObservability(builder.Configuration);
// gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role, // gRPC server plumbing shared by two endpoints: the LocalDb passive sync endpoint (driver-role,
@@ -97,7 +97,27 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </remarks> /// </remarks>
private bool _isRunningFromCache; private bool _isRunningFromCache;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory; /// <summary>Latches after the first no-op ack-write on a DB-less node so the explanatory Debug line
/// is logged once, not on every apply state transition.</summary>
private bool _loggedNoConfigDbAck;
/// <summary>
/// Config database context factory, or <see langword="null"/> on a driver-only node (per-cluster
/// mesh Phase 4). A driver-only node has NO ConfigDb — LocalDb is its config store and central is
/// the ack system of record — so every ConfigDb-touching path here is guarded on this being
/// non-null. Non-null on a fused admin+driver node and in the Direct-mode/EF test harnesses.
/// </summary>
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
/// <summary>
/// Scripted-alarm condition-state store handed to the ScriptedAlarm engine, or
/// <see langword="null"/> when none was wired (per-cluster mesh Phase 4). On a driver-role node
/// this is the replicated LocalDb store (<c>LocalDbAlarmConditionStateStore</c>) so condition
/// state survives without central SQL; when null the actor skips spawning the alarm host (the
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).
/// </summary>
private readonly IAlarmStateStore? _alarmStateStore;
private readonly CommonsNodeId _localNode; private readonly CommonsNodeId _localNode;
private readonly IActorRef? _ackRouter; private readonly IActorRef? _ackRouter;
private readonly IDriverFactory _driverFactory; private readonly IDriverFactory _driverFactory;
@@ -114,7 +134,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IVirtualTagEvaluator _virtualTagEvaluator; private readonly IVirtualTagEvaluator _virtualTagEvaluator;
private readonly IHistoryWriter _historyWriter; private readonly IHistoryWriter _historyWriter;
private readonly IActorRef? _virtualTagHostOverride; private readonly IActorRef? _virtualTagHostOverride;
private readonly ILoggerFactory _loggerFactory;
private readonly ScriptRootLogger? _scriptRootLogger; private readonly ScriptRootLogger? _scriptRootLogger;
private readonly IActorRef? _scriptedAlarmHostOverride; private readonly IActorRef? _scriptedAlarmHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -362,9 +381,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests /// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in /// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
/// production (the real host is spawned).</param> /// production (the real host is spawned).</param>
/// <param name="loggerFactory">Optional logger factory used to create the /// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
/// <see cref="EfAlarmConditionStateStore"/>'s logger when spawning the ScriptedAlarm host; /// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
/// defaults to <see cref="NullLoggerFactory"/> when not provided.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm /// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left /// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
/// unspawned — the graceful dev/None-deployment path.</param> /// unspawned — the graceful dev/None-deployment path.</param>
@@ -375,9 +393,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="invokerFactory">Optional Phase 6.1 resilience-invoker factory used to attach an /// <param name="invokerFactory">Optional Phase 6.1 resilience-invoker factory used to attach an
/// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to /// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param> /// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store, so condition state persists with no
/// ConfigDb; null skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in
/// Phase 4 Task 9). Defaults to null.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns> /// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props( public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode, CommonsNodeId localNode,
IActorRef? ackRouter = null, IActorRef? ackRouter = null,
IDriverFactory? driverFactory = null, IDriverFactory? driverFactory = null,
@@ -397,7 +419,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IRedundancyRoleView? redundancyRoleView = null, IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null, string? replicationPeerHost = null,
bool fetchAndCacheMode = false, bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null) => IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // 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 // 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 // mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -407,7 +430,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor, dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride, healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher)); deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary> /// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param> /// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -426,8 +450,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param> /// for historized VirtualTag results; defaults to <see cref="NullHistoryWriter"/>.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host /// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param> /// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
/// <param name="loggerFactory">Optional logger factory used to create the /// <param name="loggerFactory">Retained for constructor-signature stability; unused since Phase 4
/// <see cref="EfAlarmConditionStateStore"/>'s logger; defaults to <see cref="NullLoggerFactory"/>.</param> /// Task 9 retired the <c>EfAlarmConditionStateStore</c> fallback that consumed it. Defaults to null.</param>
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm /// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
/// host; when null the host is left unspawned.</param> /// host; when null the host is left unspawned.</param>
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host /// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
@@ -442,8 +466,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// When supplied, each successful apply stores its artifact so the node can boot from its /// When supplied, each successful apply stores its artifact so the node can boot from its
/// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in /// last-known-good configuration while central SQL is unreachable. Null on admin-only nodes and in
/// tests that do not exercise the cache — caching is then simply skipped.</param> /// tests that do not exercise the cache — caching is then simply skipped.</param>
/// <param name="alarmStateStore">Per-cluster mesh Phase 4: scripted-alarm condition-state store. On a
/// driver-role node this is the replicated LocalDb store; null skips spawning the alarm host (the
/// ConfigDb-backed EF fallback was retired in Phase 4 Task 9).</param>
public DriverHostActor( public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode, CommonsNodeId localNode,
IActorRef? ackRouter, IActorRef? ackRouter,
IDriverFactory? driverFactory = null, IDriverFactory? driverFactory = null,
@@ -463,13 +490,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IRedundancyRoleView? redundancyRoleView = null, IRedundancyRoleView? redundancyRoleView = null,
string? replicationPeerHost = null, string? replicationPeerHost = null,
bool fetchAndCacheMode = false, bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null) IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null)
{ {
_deploymentArtifactCache = deploymentArtifactCache; _deploymentArtifactCache = deploymentArtifactCache;
_redundancyRoleView = redundancyRoleView; _redundancyRoleView = redundancyRoleView;
_replicationPeerHost = replicationPeerHost; _replicationPeerHost = replicationPeerHost;
_fetchAndCacheMode = fetchAndCacheMode; _fetchAndCacheMode = fetchAndCacheMode;
_artifactFetcher = artifactFetcher; _artifactFetcher = artifactFetcher;
_alarmStateStore = alarmStateStore;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_localNode = localNode; _localNode = localNode;
_ackRouter = ackRouter; _ackRouter = ackRouter;
@@ -483,7 +512,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance; _virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
_historyWriter = historyWriter ?? NullHistoryWriter.Instance; _historyWriter = historyWriter ?? NullHistoryWriter.Instance;
_virtualTagHostOverride = virtualTagHostOverride; _virtualTagHostOverride = virtualTagHostOverride;
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
_scriptRootLogger = scriptRootLogger; _scriptRootLogger = scriptRootLogger;
_scriptedAlarmHostOverride = scriptedAlarmHostOverride; _scriptedAlarmHostOverride = scriptedAlarmHostOverride;
@@ -563,9 +591,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <see cref="_scriptRootLogger"/> (the engine + its script logging hang off it); when either /// <see cref="_scriptRootLogger"/> (the engine + its script logging hang off it); when either
/// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left /// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh /// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
/// <see cref="DependencyMuxTagUpstreamSource"/> + an <see cref="EfAlarmConditionStateStore"/>; /// <see cref="DependencyMuxTagUpstreamSource"/> + the wired <see cref="IAlarmStateStore"/> (the
/// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with /// replicated <c>LocalDbAlarmConditionStateStore</c> on a driver-role node); when no store was
/// the driver host. /// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its
/// PostStop, so it stops with the driver host.
/// </summary> /// </summary>
private void SpawnScriptedAlarmHost() private void SpawnScriptedAlarmHost()
{ {
@@ -583,9 +612,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
// Per-cluster mesh Phase 4: condition state lives in the wired store — the replicated LocalDb
// store on a driver-role node, which persists with no ConfigDb. The ConfigDb-backed EF fallback
// was retired (Task 9); when no store was wired there is nowhere to persist condition state, so
// we skip the alarm host outright rather than run the engine against a store it can never save to
// (mirrors the _opcUaPublishActor-null skip above).
if (_alarmStateStore is null)
{
_log.Debug(
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no condition-state store)",
_localNode);
return;
}
var store = _alarmStateStore;
var upstream = new DependencyMuxTagUpstreamSource(); var upstream = new DependencyMuxTagUpstreamSource();
var store = new EfAlarmConditionStateStore(
_dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>());
var engine = new ScriptedAlarmEngine( var engine = new ScriptedAlarmEngine(
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger); upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
_scriptedAlarmHost = Context.ActorOf( _scriptedAlarmHost = Context.ActorOf(
@@ -603,6 +644,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
// Belt-and-suspenders (Phase 4): every ConfigDb read below is on the Direct path only — a
// FetchAndCache node already returned via BootstrapFromCache above, and only a FetchAndCache node
// runs DB-less. If a null factory ever reaches a Direct path it is a misconfiguration; enter
// Steady with no revision rather than NRE, and let the first dispatch drive the apply.
if (_dbFactory is null)
{
_log.Info("DriverHost {Node}: no ConfigDb on the Direct bootstrap path; entering Steady (no revision)", _localNode);
Become(Steady);
return;
}
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump // Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
// to Steady with that revision. If Applying (orphan from a crash), discard and replay. // to Steady with that revision. If Applying (orphan from a crash), discard and replay.
// If the DB is unreachable, fall back to Stale and start the reconnect loop. // If the DB is unreachable, fall back to Stale and start the reconnect loop.
@@ -1914,6 +1966,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </remarks> /// </remarks>
private byte[]? ReconcileDrivers(DeploymentId deploymentId) private byte[]? ReconcileDrivers(DeploymentId deploymentId)
{ {
// Phase 4 belt-and-suspenders: this ConfigDb read is Direct-only (the FetchAndCache apply uses
// ReconcileDriversFromBlob with bytes in hand). A null factory can't read, so return null — the
// caller treats that as "artifact could not be read" (#486) and keeps last-known-good.
if (_dbFactory is null)
{
_log.Warning("DriverHost {Node}: no ConfigDb to load artifact for {Id}; skipping reconcile", _localNode, deploymentId);
return null;
}
byte[] blob; byte[] blob;
try try
{ {
@@ -2090,6 +2151,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary> /// </summary>
private void PushDesiredSubscriptions(DeploymentId deploymentId) private void PushDesiredSubscriptions(DeploymentId deploymentId)
{ {
// Phase 4 belt-and-suspenders: Direct-only read (the FetchAndCache path calls
// PushDesiredSubscriptionsFromArtifact with bytes in hand). No ConfigDb ⇒ nothing to load.
if (_dbFactory is null)
{
_log.Warning("DriverHost {Node}: no ConfigDb to load artifact for SubscribeBulk ({Id}); skipping", _localNode, deploymentId);
return;
}
byte[] blob; byte[] blob;
try try
{ {
@@ -2575,8 +2644,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{ {
// Defensive: FetchAndCache never enters Stale (BootstrapFromCache always Becomes Steady, so // Defensive: FetchAndCache never enters Stale (BootstrapFromCache always Becomes Steady, so
// the retry-db timer that drives this is never started), and its recovery must NOT read // the retry-db timer that drives this is never started), and its recovery must NOT read
// central SQL. If we somehow land here, leave Steady rather than re-reading Deployments. // central SQL. A DB-less node (null factory, Phase 4) likewise has nothing to recover from. If
if (_fetchAndCacheMode) // we somehow land here, leave Steady rather than re-reading Deployments.
if (_fetchAndCacheMode || _dbFactory is null)
{ {
Timers.Cancel("retry-db"); Timers.Cancel("retry-db");
Become(Steady); Become(Steady);
@@ -2612,6 +2682,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private void UpsertNodeDeploymentState(DeploymentId deploymentId, NodeDeploymentStatus status, string? failureReason) private void UpsertNodeDeploymentState(DeploymentId deploymentId, NodeDeploymentStatus status, string? failureReason)
{ {
// Per-cluster mesh Phase 4: a driver-only node has NO ConfigDb. Central is the ack system of
// record — ConfigPublishCoordinator.PersistNodeAck already writes NodeDeploymentState from every
// ApplyAck it receives (and seeds the Applying rows at dispatch) — so this node's own upsert is
// redundant and simply no-ops here with no loss of information. Logged once to keep the reason
// discoverable without spamming every apply transition.
if (_dbFactory is null)
{
if (!_loggedNoConfigDbAck)
{
_loggedNoConfigDbAck = true;
_log.Debug(
"DriverHost {Node}: no ConfigDb — NodeDeploymentState written by central from the ApplyAck",
_localNode);
}
return;
}
try try
{ {
using var db = _dbFactory.CreateDbContext(); using var db = _dbFactory.CreateDbContext();
@@ -103,6 +103,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory; private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier; private readonly AddressSpaceApplier? _applier;
private readonly IActorRef? _dbHealthProbe; private readonly IActorRef? _dbHealthProbe;
/// <summary>Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its
/// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired —
/// <c>DbReachable</c> is fed constant <c>true</c> and only snapshot staleness can demote. See
/// <see cref="RecomputeServiceLevel"/>.</summary>
private readonly bool _dbLess;
private readonly TimeSpan _staleWindow; private readonly TimeSpan _staleWindow;
private readonly TimeSpan _probeFreshnessWindow; private readonly TimeSpan _probeFreshnessWindow;
private readonly TimeSpan _healthTickInterval; private readonly TimeSpan _healthTickInterval;
@@ -153,6 +158,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each /// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is /// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param> /// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor, pinned to the /// <returns>The configured <see cref="Props"/> for creating this actor, pinned to the
/// <see cref="DispatcherId"/> dispatcher.</returns> /// <see cref="DispatcherId"/> dispatcher.</returns>
public static Props Props( public static Props Props(
@@ -164,7 +172,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
IActorRef? dbHealthProbe = null, IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null, TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null, TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null) => TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor( Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance, sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -175,7 +184,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
dbHealthProbe, dbHealthProbe,
staleWindow, staleWindow,
probeFreshnessWindow, probeFreshnessWindow,
healthTickInterval)).WithDispatcher(DispatcherId); healthTickInterval,
dbLess)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the /// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary> /// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -195,6 +205,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each /// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is /// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param> /// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor in tests, without dispatcher /// <returns>The configured <see cref="Props"/> for creating this actor in tests, without dispatcher
/// pinning or the DPS subscribe.</returns> /// pinning or the DPS subscribe.</returns>
public static Props PropsForTests( public static Props PropsForTests(
@@ -207,7 +220,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
IActorRef? dbHealthProbe = null, IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null, TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null, TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null) => TimeSpan? healthTickInterval = null,
bool dbLess = false) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor( Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance, sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance,
@@ -218,7 +232,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
dbHealthProbe, dbHealthProbe,
staleWindow, staleWindow,
probeFreshnessWindow, probeFreshnessWindow,
healthTickInterval)); healthTickInterval,
dbLess));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param> /// <param name="sink">The OPC UA address space sink.</param>
@@ -237,6 +252,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each /// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is /// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param> /// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <param name="dbLess">Per-cluster mesh Phase 4: this is a driver-only node with no ConfigDb, so the
/// ServiceLevel DB-reachability input is retired (<c>DbReachable</c> treated constant-true, only
/// staleness demotes). No <paramref name="dbHealthProbe"/> is wired on such a node.</param>
public OpcUaPublishActor( public OpcUaPublishActor(
IOpcUaAddressSpaceSink sink, IOpcUaAddressSpaceSink sink,
IServiceLevelPublisher serviceLevel, IServiceLevelPublisher serviceLevel,
@@ -247,7 +265,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
IActorRef? dbHealthProbe = null, IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null, TimeSpan? staleWindow = null,
TimeSpan? probeFreshnessWindow = null, TimeSpan? probeFreshnessWindow = null,
TimeSpan? healthTickInterval = null) TimeSpan? healthTickInterval = null,
bool dbLess = false)
{ {
_sink = sink; _sink = sink;
_serviceLevel = serviceLevel; _serviceLevel = serviceLevel;
@@ -256,6 +275,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
_dbFactory = dbFactory; _dbFactory = dbFactory;
_applier = applier; _applier = applier;
_dbHealthProbe = dbHealthProbe; _dbHealthProbe = dbHealthProbe;
_dbLess = dbLess;
_staleWindow = staleWindow ?? TimeSpan.FromSeconds(30); _staleWindow = staleWindow ?? TimeSpan.FromSeconds(30);
_probeFreshnessWindow = probeFreshnessWindow ?? TimeSpan.FromSeconds(30); _probeFreshnessWindow = probeFreshnessWindow ?? TimeSpan.FromSeconds(30);
_healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5); _healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5);
@@ -339,10 +359,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
// Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against // Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
// the latest deployment artifact. Without them, fall back to a raw sink rebuild — the // artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
// F10b/dev path before the integration completes. // ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
if (_dbFactory is null || _applier is null) // msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
// without re-materialising, so it must NEVER be reached by a real driver node that has an
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
if (_applier is null)
{ {
try try
{ {
@@ -359,15 +384,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
try try
{ {
// An in-hand artifact (boot-from-cache) wins: the host already has the cached bytes, and // An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
// the ConfigDb read the DeploymentId path would do is exactly what is unreachable during // cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
// the outage this exists to survive. Otherwise prefer the artifact of the deployment the // is unreachable during the outage this exists to survive — and is simply absent on a
// host just applied — at apply time it is not yet Sealed, so LoadLatestArtifact would // driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
// return the PREVIOUS revision and materialise a stale composition (variables that don't // prefer the artifact of the deployment the host just applied — at apply time it is not yet
// match the SubscribeBulk refs). Fall back to latest-sealed only for legacy callers that // Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
// carry neither. // composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
// to load, so yield Array.Empty<byte>() — the #485 guard below reads that as "no answer" and
// abandons the rebuild, holding last-known-good rather than wiping the address space.
var artifact = msg.Artifact var artifact = msg.Artifact
?? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact()); ?? (_dbFactory is not null
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
: Array.Empty<byte>());
// Issue #485 — an artifact we could not obtain is NOT an empty configuration. Parsing zero // Issue #485 — an artifact we could not obtain is NOT an empty configuration. Parsing zero
// bytes yields an empty composition, which the planner diffs against the live one as a // bytes yields an empty composition, which the planner diffs against the live one as a
@@ -635,6 +665,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return; return;
} }
// Per-cluster mesh Phase 4: on a driver-only (DB-less) node there is no ConfigDb to probe —
// LocalDb IS the config store and is in-process, so it can never be "unreachable". Retire the
// DB-reachability input by feeding DbReachable=true CONSTANT and deriving staleness from the
// snapshot age ALONE (no DB-health term). This is the survive-alone posture and it is
// client-visible: a healthy DB-less node must publish 240 (240+10=250 as Primary) even with
// central down, so clients keep steering to it — the old DbReachable=false would have computed
// the (false,true,false) → 0 arm and driven clients AWAY from a node serving perfectly.
// Staleness ("running behind on config") still demotes to 200. The Detached guard above still
// wins, so a DB-less Detached node stays 0.
if (_dbLess)
{
var utcNow = DateTime.UtcNow;
var dbLessInputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: true,
OpcUaProbeOk: OpcUaProbeOk(),
Stale: (utcNow - entry.AsOfUtc) > _staleWindow,
IsDriverPrimary: entry.IsDriverPrimary);
Self.Tell(new ServiceLevelChanged(ServiceLevelCalculator.Compute(dbLessInputs)));
return;
}
// Legacy / back-compat seam: with no DB-health probe wired (or before the first sample // Legacy / back-compat seam: with no DB-health probe wired (or before the first sample
// arrives) fall back to the old role-only switch. This preserves historical behaviour and // arrives) fall back to the old role-only switch. This preserves historical behaviour and
// is the bootstrap value until the first DbHealthStatus lands. // is the bootstrap value until the first DbHealthStatus lands.
@@ -1,240 +0,0 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// <summary>
/// Production-side <see cref="IAlarmStateStore"/> backed by the
/// <see cref="ScriptedAlarmState"/> table in the central config DB. This store maps the
/// full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed / Shelving
/// + the ack/confirm audit trail + operator comments.
/// </summary>
/// <remarks>
/// <para>
/// <b>ActiveState is NOT persisted</b> — the entity has no Active column. On
/// <see cref="LoadAsync"/> it is restored as <see cref="AlarmActiveState.Inactive"/>;
/// the engine re-derives it from the live predicate on startup.
/// </para>
/// <para>
/// <b>LastTransitionUtc ↔ UpdatedAtUtc</b>: the table has no dedicated transition
/// column, so <c>LastTransitionUtc</c> is written into the row-write
/// <see cref="ScriptedAlarmState.UpdatedAtUtc"/> on save and read back from it on load.
/// </para>
/// <para>
/// <b>LastActiveUtc / LastClearedUtc are transient</b> — they have no columns and
/// default to <c>null</c> on load (they re-derive from the predicate alongside Active).
/// </para>
/// <para>
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
/// round-trips as <c>"[]"</c> (matching the entity default).
/// </para>
/// </remarks>
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly ILogger<EfAlarmConditionStateStore> _logger;
/// <summary>Initializes a new instance of the <see cref="EfAlarmConditionStateStore"/>.</summary>
/// <param name="dbFactory">The factory for creating config database contexts.</param>
/// <param name="logger">The logger instance.</param>
public EfAlarmConditionStateStore(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ILogger<EfAlarmConditionStateStore> logger)
{
_dbFactory = dbFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var row = await db.ScriptedAlarmStates.AsNoTracking()
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
.ConfigureAwait(false);
return row is null ? null : MapToState(row);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var rows = await db.ScriptedAlarmStates.AsNoTracking()
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(MapToState).ToArray();
}
/// <inheritdoc />
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var row = await db.ScriptedAlarmStates
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct)
.ConfigureAwait(false);
if (row is null)
{
// Required members are set here to satisfy the compiler; ApplyState is the single
// source of truth for all columns and immediately overwrites these values below.
row = new ScriptedAlarmState
{
ScriptedAlarmId = state.AlarmId,
EnabledState = MapEnabledToColumn(state.Enabled),
AckedState = MapAckedToColumn(state.Acked),
ConfirmedState = MapConfirmedToColumn(state.Confirmed),
ShelvingState = MapShelvingToColumn(state.Shelving.Kind),
};
ApplyState(row, state);
db.ScriptedAlarmStates.Add(row);
}
else
{
ApplyState(row, state);
}
try
{
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException ex)
{
// Two writers racing to save the same alarm is benign — last writer wins on
// UpdatedAtUtc and the next transition writes again. Log + drop so a race never
// crashes the engine.
_logger.LogDebug(ex,
"EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save",
state.AlarmId);
}
}
/// <inheritdoc />
public async Task RemoveAsync(string alarmId, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var row = await db.ScriptedAlarmStates
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
.ConfigureAwait(false);
if (row is null) return;
db.ScriptedAlarmStates.Remove(row);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state)
{
row.EnabledState = MapEnabledToColumn(state.Enabled);
row.AckedState = MapAckedToColumn(state.Acked);
row.ConfirmedState = MapConfirmedToColumn(state.Confirmed);
row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind);
row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc;
row.LastAckUser = state.LastAckUser;
row.LastAckComment = state.LastAckComment;
row.LastAckUtc = state.LastAckUtc;
row.LastConfirmUser = state.LastConfirmUser;
row.LastConfirmComment = state.LastConfirmComment;
row.LastConfirmUtc = state.LastConfirmUtc;
row.CommentsJson = SerializeComments(state.Comments);
// No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc.
row.UpdatedAtUtc = state.LastTransitionUtc;
}
private static AlarmConditionState MapToState(ScriptedAlarmState row) => new(
AlarmId: row.ScriptedAlarmId,
Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal)
? AlarmEnabledState.Disabled
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
// Active is not persisted — the engine re-derives it from the predicate at startup.
Active: AlarmActiveState.Inactive,
Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal)
? AlarmAckedState.Acknowledged
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
? AlarmConfirmedState.Confirmed
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc),
// No transition column — UpdatedAtUtc carries the last transition timestamp.
LastTransitionUtc: row.UpdatedAtUtc,
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: row.LastAckUtc,
LastAckUser: row.LastAckUser,
LastAckComment: row.LastAckComment,
LastConfirmUtc: row.LastConfirmUtc,
LastConfirmUser: row.LastConfirmUser,
LastConfirmComment: row.LastConfirmComment,
Comments: DeserializeComments(row.CommentsJson));
private static string MapEnabledToColumn(AlarmEnabledState enabled)
=> enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
private static string MapAckedToColumn(AlarmAckedState acked)
=> acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
=> confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
{
ShelvingKind.OneShot => "OneShotShelved",
ShelvingKind.Timed => "TimedShelved",
_ => "Unshelved",
};
private static ShelvingKind MapShelvingFromColumn(string column) => column switch
{
"OneShotShelved" => ShelvingKind.OneShot,
"TimedShelved" => ShelvingKind.Timed,
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
};
private static string SerializeComments(ImmutableList<AlarmComment> comments)
{
if (comments.IsEmpty) return "[]";
var dtos = comments.Select(c => new CommentDto
{
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
// the engine always creates AlarmComment instances with Utc kind.
TimestampUtc = c.TimestampUtc,
User = c.User,
Kind = c.Kind,
Text = c.Text,
});
return JsonSerializer.Serialize(dtos, JsonOptions);
}
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
return dtos
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToImmutableList();
}
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>CommentsJson</c>.</summary>
private sealed class CommentDto
{
/// <summary>When the comment was recorded (UTC).</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor that wrote the comment.</summary>
public string? User { get; set; }
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
public string? Kind { get; set; }
/// <summary>Operator-supplied or engine-generated comment text.</summary>
public string? Text { get; set; }
}
}
@@ -0,0 +1,264 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// <summary>
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
/// Shelving + the ack/confirm audit trail + operator comments.
/// </summary>
/// <remarks>
/// <para>
/// The LocalDb replacement for <c>EfAlarmConditionStateStore</c> (which persisted to the
/// central config DB's <c>ScriptedAlarmState</c> table). Per-cluster mesh Phase 4 cuts the
/// ConfigDb from driver-only nodes, so this state moves into the same replicated LocalDb
/// file the Phase-2 alarm store-and-forward buffer already lives in — a node's condition
/// state then mirrors to its redundant pair peer instead of depending on central SQL.
/// </para>
/// <para>
/// <b>The round-trip is byte-identical to the EF store</b> — the enum↔string mappers and the
/// comment serialization below are duplicated verbatim from it (it is deleted in a later
/// task; the ported round-trip tests prove parity). The persistence rules carry over
/// unchanged:
/// </para>
/// <para>
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
/// </para>
/// <para>
/// <b>Save is a single unconditional PK upsert.</b> No application-level last-write-wins or
/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
/// alarm-sf sink and the deployment pointer follow).
/// </para>
/// </remarks>
public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
// Fixed column order shared by every read — the reader map below indexes by these ordinals.
private const string SelectColumns =
"scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
"shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
"last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
private readonly ILocalDb _db;
private readonly ILogger<LocalDbAlarmConditionStateStore> _logger;
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
/// <param name="db">
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
/// must already exist and be registered for replication — the host does both in
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
/// </param>
/// <param name="logger">The logger instance.</param>
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(alarmId);
var rows = await _db.QueryAsync(
$"SELECT {SelectColumns} FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
MapRow,
new { Id = alarmId },
ct).ConfigureAwait(false);
return rows.Count > 0 ? rows[0] : null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
{
var rows = await _db.QueryAsync(
$"SELECT {SelectColumns} FROM alarm_condition_state",
MapRow,
parameters: null,
ct).ConfigureAwait(false);
return rows;
}
/// <inheritdoc />
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(state);
// A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
// last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
// app-level LWW here (see the class remarks).
await _db.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc,
last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc)
VALUES
(@Id, @Enabled, @Acked, @Confirmed, @Shelving,
@ShelvingExpires, @LastAckUser, @LastAckComment, @LastAckUtc,
@LastConfirmUser, @LastConfirmComment, @LastConfirmUtc, @CommentsJson, @UpdatedAtUtc)
ON CONFLICT(scripted_alarm_id) DO UPDATE SET
enabled_state = excluded.enabled_state,
acked_state = excluded.acked_state,
confirmed_state = excluded.confirmed_state,
shelving_state = excluded.shelving_state,
shelving_expires_utc = excluded.shelving_expires_utc,
last_ack_user = excluded.last_ack_user,
last_ack_comment = excluded.last_ack_comment,
last_ack_utc = excluded.last_ack_utc,
last_confirm_user = excluded.last_confirm_user,
last_confirm_comment = excluded.last_confirm_comment,
last_confirm_utc = excluded.last_confirm_utc,
comments_json = excluded.comments_json,
updated_at_utc = excluded.updated_at_utc
""",
new
{
Id = state.AlarmId,
Enabled = MapEnabledToColumn(state.Enabled),
Acked = MapAckedToColumn(state.Acked),
Confirmed = MapConfirmedToColumn(state.Confirmed),
Shelving = MapShelvingToColumn(state.Shelving.Kind),
ShelvingExpires = FormatNullable(state.Shelving.UnshelveAtUtc),
LastAckUser = state.LastAckUser,
LastAckComment = state.LastAckComment,
LastAckUtc = FormatNullable(state.LastAckUtc),
LastConfirmUser = state.LastConfirmUser,
LastConfirmComment = state.LastConfirmComment,
LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
CommentsJson = SerializeComments(state.Comments),
// No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
UpdatedAtUtc = Format(state.LastTransitionUtc),
},
ct).ConfigureAwait(false);
_logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
}
/// <inheritdoc />
public async Task RemoveAsync(string alarmId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(alarmId);
await _db.ExecuteAsync(
"DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
new { Id = alarmId },
ct).ConfigureAwait(false);
}
private static AlarmConditionState MapRow(SqliteDataReader r) => new(
AlarmId: r.GetString(0),
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
? AlarmEnabledState.Disabled
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
// Active is not persisted — the engine re-derives it from the predicate at startup.
Active: AlarmActiveState.Inactive,
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
? AlarmAckedState.Acknowledged
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
? AlarmConfirmedState.Confirmed
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
// No transition column — updated_at_utc carries the last transition timestamp.
LastTransitionUtc: Parse(r.GetString(13)),
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: ParseNullable(r, 8),
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
LastConfirmUtc: ParseNullable(r, 11),
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
Comments: DeserializeComments(r.GetString(12)));
// Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
// the DateTimeKind.Utc round-trips exactly.
private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
private static DateTime Parse(string value) =>
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
private static string MapEnabledToColumn(AlarmEnabledState enabled)
=> enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
private static string MapAckedToColumn(AlarmAckedState acked)
=> acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
=> confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
{
ShelvingKind.OneShot => "OneShotShelved",
ShelvingKind.Timed => "TimedShelved",
_ => "Unshelved",
};
private static ShelvingKind MapShelvingFromColumn(string column) => column switch
{
"OneShotShelved" => ShelvingKind.OneShot,
"TimedShelved" => ShelvingKind.Timed,
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
};
private static string SerializeComments(ImmutableList<AlarmComment> comments)
{
if (comments.IsEmpty) return "[]";
var dtos = comments.Select(c => new CommentDto
{
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
// the engine always creates AlarmComment instances with Utc kind.
TimestampUtc = c.TimestampUtc,
User = c.User,
Kind = c.Kind,
Text = c.Text,
});
return JsonSerializer.Serialize(dtos, JsonOptions);
}
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
return dtos
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToImmutableList();
}
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
private sealed class CommentDto
{
/// <summary>When the comment was recorded (UTC).</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor that wrote the comment.</summary>
public string? User { get; set; }
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
public string? Kind { get; set; }
/// <summary>Operator-supplied or engine-generated comment text.</summary>
public string? Text { get; set; }
}
}
@@ -18,6 +18,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -282,6 +283,11 @@ public static class ServiceCollectionExtensions
// harnesses) rather than given a null-object, so DriverHostActor skips caching outright // harnesses) rather than given a null-object, so DriverHostActor skips caching outright
// instead of pretending to cache into a sink that drops everything. // instead of pretending to cache into a sink that drops everything.
var deploymentArtifactCache = resolver.GetService<IDeploymentArtifactCache>(); var deploymentArtifactCache = resolver.GetService<IDeploymentArtifactCache>();
// Per-cluster mesh Phase 4: scripted-alarm condition-state store. Registered by the Host's
// AddOtOpcUaLocalDb on driver-role nodes (the replicated LocalDb store), so condition state
// persists with no ConfigDb; null elsewhere (admin-only graphs, test harnesses) → the actor
// skips spawning the alarm host (the ConfigDb-backed EF fallback was retired in Phase 4 Task 9).
var alarmStateStore = resolver.GetService<IAlarmStateStore>();
// Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config. // Per-cluster mesh Phase 3: ConfigSource:Mode selects where this driver reads config.
// FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache; // FetchAndCache pulls the artifact from central over gRPC and reads only the LocalDb cache;
// Direct (default) reads central SQL as before. The fetcher is resolved only in FetchAndCache // Direct (default) reads central SQL as before. The fetcher is resolved only in FetchAndCache
@@ -326,10 +332,19 @@ public static class ServiceCollectionExtensions
// (durable AVEVA sink is infra-gated); a deployment binding a real IHistoryWriter overrides. // (durable AVEVA sink is infra-gated); a deployment binding a real IHistoryWriter overrides.
var historyWriter = resolver.GetService<IHistoryWriter>() ?? NullHistoryWriter.Instance; var historyWriter = resolver.GetService<IHistoryWriter>() ?? NullHistoryWriter.Instance;
var dbHealth = system.ActorOf( // Per-cluster mesh Phase 4: a driver-only node has NO ConfigDb (LocalDb is its config
DbHealthProbeActor.Props(dbFactory), // store), so dbFactory is null and there is nothing to probe. Skip spawning the probe
DbHealthProbeActorName); // entirely — spawning it with a null factory would NRE on the first SELECT 1 — and do not
registry.Register<DbHealthProbeActorKey>(dbHealth); // register its marker key. OpcUaPublishActor then runs in dbLess mode (DbReachable retired,
// treated constant-true) so a healthy DB-less node still publishes full ServiceLevel.
IActorRef? dbHealth = null;
if (dbFactory is not null)
{
dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
DbHealthProbeActorName);
registry.Register<DbHealthProbeActorKey>(dbHealth);
}
// Dependency mux must be spawned before DriverHostActor so the host can forward // Dependency mux must be spawned before DriverHostActor so the host can forward
// AttributeValuePublished into it from the very first driver spawn. // AttributeValuePublished into it from the very first driver spawn.
@@ -436,7 +451,8 @@ public static class ServiceCollectionExtensions
localNode: roleInfo.LocalNode, localNode: roleInfo.LocalNode,
dbFactory: dbFactory, dbFactory: dbFactory,
applier: applier, applier: applier,
dbHealthProbe: dbHealth), dbHealthProbe: dbHealth,
dbLess: dbFactory is null),
OpcUaPublishActorName); OpcUaPublishActorName);
registry.Register<OpcUaPublishActorKey>(publishActor); registry.Register<OpcUaPublishActorKey>(publishActor);
@@ -452,6 +468,12 @@ public static class ServiceCollectionExtensions
// ackRouter: under ClusterClient mode the node comm actor relays ApplyAcks across // ackRouter: under ClusterClient mode the node comm actor relays ApplyAcks across
// the boundary; under Dps it stays null and the host publishes on the // the boundary; under Dps it stays null and the host publishes on the
// deployment-acks topic exactly as before. // deployment-acks topic exactly as before.
// dbFactory is nullable (Phase 4): a driver-only node passes null here — ConfigDb is
// gated on hasAdmin and driver-only nodes are forced to FetchAndCache. DriverHostActor
// guards every ConfigDb-touching path on it (UpsertNodeDeploymentState no-ops; central
// persists acks from the ApplyAck) and serves scripted-alarm state from the LocalDb
// alarmStateStore (there is no ConfigDb-backed EF fallback; the host skips the alarm
// host when no store was wired).
DriverHostActor.Props(dbFactory, roleInfo.LocalNode, DriverHostActor.Props(dbFactory, roleInfo.LocalNode,
ackRouter: useClusterClient ? nodeComm : null, ackRouter: useClusterClient ? nodeComm : null,
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
@@ -467,7 +489,8 @@ public static class ServiceCollectionExtensions
redundancyRoleView: redundancyRoleView, redundancyRoleView: redundancyRoleView,
replicationPeerHost: replicationPeerHost, replicationPeerHost: replicationPeerHost,
fetchAndCacheMode: fetchAndCacheMode, fetchAndCacheMode: fetchAndCacheMode,
artifactFetcher: artifactFetcher), artifactFetcher: artifactFetcher,
alarmStateStore: alarmStateStore),
DriverHostActorName); DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost); registry.Register<DriverHostActorKey>(driverHost);
@@ -0,0 +1,44 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <summary>
/// Driver-only-node stand-in for the EF-backed <see cref="ILdapGroupRoleMappingService"/>.
/// A driver-only node (Akka role <c>driver</c>, not <c>admin</c>) has no ConfigDb connection
/// (Phase 4 gated <c>AddOtOpcUaConfigDb</c> on <c>hasAdmin</c>), so it can never read the
/// control-plane <c>LdapGroupRoleMapping</c> DB grants that <see cref="OtOpcUaGroupRoleMapper"/>
/// normally unions into its result. This implementation always reports "no DB grants" so the
/// data-path mapper falls back to exactly the appsettings <c>Security:Ldap:GroupToRole</c>
/// baseline — the same safe fallback the mapper already takes when the real service simply
/// has no matching rows. <see cref="LdapGroupRoleMapping"/> rows are not composed into the
/// deployment artifact either, so a driver node never had DB grants via config; reporting
/// empty here is honest, not lossy.
/// </summary>
/// <remarks>
/// The write surface (<see cref="CreateAsync"/>/<see cref="DeleteAsync"/>) is control-plane-only
/// Admin UI functionality that a driver-only node — which has no Admin UI — never calls. It
/// throws rather than silently no-opping so an accidental call surfaces immediately instead of
/// pretending to persist a grant that a driver-only node cannot store.
/// </remarks>
public sealed class NullLdapGroupRoleMappingService : ILdapGroupRoleMappingService
{
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(Array.Empty<LdapGroupRoleMapping>());
/// <inheritdoc />
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(Array.Empty<LdapGroupRoleMapping>());
/// <inheritdoc />
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
=> throw new NotSupportedException(
"LDAP group-role grants are control-plane only; a driver-only node has no ConfigDb.");
/// <inheritdoc />
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
=> throw new NotSupportedException(
"LDAP group-role grants are control-plane only; a driver-only node has no ConfigDb.");
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
@@ -11,8 +12,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// </summary> /// </summary>
public class ConfigSourceOptionsValidatorTests public class ConfigSourceOptionsValidatorTests
{ {
private static ValidateOptionsResult Validate(ConfigSourceOptions o) => private static ValidateOptionsResult Validate(ConfigSourceOptions o, params string[] roles)
new ConfigSourceOptionsValidator().Validate(ConfigSourceOptions.SectionName, o); {
var pairs = new Dictionary<string, string?>();
for (var i = 0; i < roles.Length; i++)
{
pairs[$"Cluster:Roles:{i}"] = roles[i];
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
return new ConfigSourceOptionsValidator(configuration).Validate(ConfigSourceOptions.SectionName, o);
}
private static ConfigSourceOptions ValidFetch() => new() private static ConfigSourceOptions ValidFetch() => new()
{ {
@@ -117,4 +128,41 @@ public class ConfigSourceOptionsValidatorTests
FetchTimeoutSeconds = 0, FetchTimeoutSeconds = 0,
}).Succeeded.ShouldBeTrue(); }).Succeeded.ShouldBeTrue();
} }
[Fact]
public void Driver_only_node_on_Direct_fails()
{
// A driver-only node has no central ConfigDb connection (Phase 4) — Direct silently produces a
// node that can never read its configuration.
var result = Validate(
new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect },
"driver");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(ConfigSourceOptions.ModeFetchAndCache);
}
[Fact]
public void Fused_admin_and_driver_node_on_Direct_is_valid()
{
// A fused node holds the ConfigDb connection via its admin role, so Direct is still legitimate.
Validate(
new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect },
"admin", "driver").Succeeded.ShouldBeTrue();
}
[Fact]
public void Driver_only_node_on_FetchAndCache_is_valid()
{
Validate(ValidFetch(), "driver").Succeeded.ShouldBeTrue();
}
[Fact]
public void Admin_only_node_on_Direct_is_valid()
{
// An admin-only node has no driver role at all, so the driver-only rule does not apply to it.
Validate(
new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect },
"admin").Succeeded.ShouldBeTrue();
}
} }
@@ -36,8 +36,10 @@ public sealed class SchemaComplianceTests
"DriverHostStatus", "DriverHostStatus",
"DriverInstanceResilienceStatus", "DriverInstanceResilienceStatus",
"LdapGroupRoleMapping", "LdapGroupRoleMapping",
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables // v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables.
"Script", "ScriptedAlarm", "ScriptedAlarmState", // ScriptedAlarmState was dropped in per-cluster mesh Phase 4 (Task 9) — scripted-alarm
// condition state moved to the replicated LocalDb store.
"Script", "ScriptedAlarm",
// v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment) // v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
"Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys", "Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
}; };
@@ -10,9 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary> /// <summary>
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>, /// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in /// <see cref="ScriptedAlarm"/>) register correctly in the EF model, map to the expected
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints /// tables/columns/indexes, and carry the check constraints the plan decisions call for.
/// the plan decisions call for. Introspection only — no SQL Server required. /// Introspection only — no SQL Server required.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class ScriptingEntitiesTests public sealed class ScriptingEntitiesTests
@@ -127,49 +127,9 @@ public sealed class ScriptingEntitiesTests
alarm.Enabled.ShouldBeTrue(); alarm.Enabled.ShouldBeTrue();
} }
/// <summary>Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped.</summary> // The ConfigDb-backed ScriptedAlarmState entity + table were retired in per-cluster mesh Phase 4
[Fact] // (Task 9): scripted-alarm condition state lives in the replicated LocalDb store. The entity-model
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped() // tests that covered it are gone with it.
{
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
entity.GetTableName().ShouldBe("ScriptedAlarmState");
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
pk.Properties.Count.ShouldBe(1);
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
entity.FindProperty("GenerationId").ShouldBeNull(
"ack state follows alarm identity across generations");
}
/// <summary>Verifies that ScriptedAlarmState default values match Part 9 initial states.</summary>
[Fact]
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
{
var state = new ScriptedAlarmState
{
ScriptedAlarmId = "a1",
EnabledState = "Enabled",
AckedState = "Unacknowledged",
ConfirmedState = "Unconfirmed",
ShelvingState = "Unshelved",
};
state.CommentsJson.ShouldBe("[]");
state.LastAckUser.ShouldBeNull();
state.LastAckUtc.ShouldBeNull();
}
/// <summary>Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson.</summary>
[Fact]
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
{
using var ctx = BuildCtx();
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
}
/// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary> /// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary>
[Fact] [Fact]
@@ -179,7 +139,6 @@ public sealed class ScriptingEntitiesTests
ctx.Scripts.ShouldNotBeNull(); ctx.Scripts.ShouldNotBeNull();
ctx.VirtualTags.ShouldNotBeNull(); ctx.VirtualTags.ShouldNotBeNull();
ctx.ScriptedAlarms.ShouldNotBeNull(); ctx.ScriptedAlarms.ShouldNotBeNull();
ctx.ScriptedAlarmStates.ShouldNotBeNull();
} }
/// <summary>Verifies that the squashed V3Initial migration exists in the assembly.</summary> /// <summary>Verifies that the squashed V3Initial migration exists in the assembly.</summary>
@@ -0,0 +1,46 @@
using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
/// <summary>
/// Verifies <see cref="AlarmConditionStateSchema.Apply"/> creates its table and is idempotent —
/// the DDL depends on nothing but a <see cref="SqliteConnection"/>, so a bare in-memory
/// connection is a faithful harness (no <c>zb_hlc_next()</c> UDF is involved in <c>CREATE TABLE</c>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AlarmConditionStateSchemaTests
{
private static SqliteConnection OpenInMemory()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
return connection;
}
/// <summary>A single Apply creates the table.</summary>
[Fact]
public void Apply_creates_the_state_table()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @Name";
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.StateTable);
((long)cmd.ExecuteScalar()!).ShouldBe(1);
}
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
[Fact]
public void Apply_is_idempotent()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection));
}
}
@@ -0,0 +1,210 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the <c>admin</c> role.
/// A driver-only node (Akka role <c>driver</c>, NOT <c>admin</c>) boots with NO <c>ConfigDb</c>
/// connection string at all; its configuration arrives from central via the Phase-3
/// <c>ConfigSource:Mode=FetchAndCache</c> path.
/// </summary>
/// <remarks>
/// <para>
/// These tests replicate the exact ConfigDb gating decision Program.cs makes —
/// <c>if (hasAdmin) services.AddOtOpcUaConfigDb(configuration)</c> — over the real
/// <see cref="ServiceCollectionExtensions.AddOtOpcUaConfigDb"/> method, for both role shapes.
/// They deliberately do NOT boot the full host: Program.cs reads roles from the
/// <c>OTOPCUA_ROLES</c> process env var and starting the host triggers the Akka cluster join
/// (see <c>TwoNodeClusterHarness</c> on why <c>WebApplicationFactory&lt;Program&gt;</c> is not
/// driven here, and <c>LocalDbWiringTests</c> for the same partial-graph harness model). The
/// full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task.
/// </para>
/// </remarks>
public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
{
private readonly List<WebApplication> _apps = [];
/// <summary>
/// Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff
/// <paramref name="hasAdmin"/>. A driver-only node passes <c>hasAdmin: false</c> and supplies
/// no <c>ConnectionStrings:ConfigDb</c> at all.
/// </summary>
private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
if (configDbConnectionString is not null)
{
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:ConfigDb"] = configDbConnectionString,
});
}
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
var app = builder.Build();
_apps.Add(app);
return app.Services;
}
[Fact]
public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory()
{
// The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must
// still build its service graph without throwing — nothing in the shared registrations may
// reach for the ConfigDb factory.
IServiceProvider sp = null!;
Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null));
sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldBeNull();
}
[Fact]
public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory()
{
// The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before.
var sp = BuildSharedServicesGraph(
hasAdmin: true,
configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");
sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldNotBeNull();
}
[Fact]
public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws()
{
// Why the gate exists: the unconditional call Program.cs made throws on a driver-only node,
// which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids.
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
Should.Throw<InvalidOperationException>(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
}
/// <summary>
/// Replicates Program.cs's role-mapping DI ordering, top to bottom:
/// <list type="number">
/// <item><description>
/// the <c>hasAdmin</c> block — <c>AddOtOpcUaConfigDb</c>, which plain-<c>AddScoped</c>s
/// the real <see cref="LdapGroupRoleMappingService"/> (line ~132 in Program.cs);
/// </description></item>
/// <item><description>
/// <c>AddValidatedOptions&lt;LdapOptions, LdapOptionsValidator&gt;</c>, registered
/// unconditionally for every role shape (line ~143);
/// </description></item>
/// <item><description>
/// the <c>hasDriver</c> block's three <c>TryAdd</c> registrations (lines ~333-335):
/// <see cref="NullLdapGroupRoleMappingService"/>, <see cref="OtOpcUaGroupRoleMapper"/>.
/// </description></item>
/// </list>
/// On a FUSED node this ordering is exactly what lets the real EF-backed service win the
/// <c>TryAdd</c> — a plain <c>AddScoped</c> that ran first already claimed the descriptor slot,
/// so the later <c>TryAddScoped</c> is a no-op. Nothing enforces that order beyond Program.cs's
/// own top-to-bottom script. Verified by locally inverting the ordering to (3)-then-(1) AND
/// switching <c>AddOtOpcUaConfigDb</c>'s <c>ILdapGroupRoleMappingService</c> registration to a
/// <c>TryAddScoped</c> (both reverted before commit) — that combination is what flips the
/// resolved type to <see cref="NullLdapGroupRoleMappingService"/> and turns this test red;
/// either change alone stays green today (an unconditional <c>AddScoped</c> always wins
/// last-registered regardless of order, and a <c>TryAdd</c> that runs first still wins). Security:
/// Ldap:Enabled is forced false here purely so <see cref="LdapOptionsValidator"/> doesn't demand
/// a live Server/SearchBase/Transport — it is orthogonal to what is under test.
/// </summary>
private IServiceProvider BuildRoleMappingGraph(bool hasAdmin, string? configDbConnectionString)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
var config = new Dictionary<string, string?> { ["Security:Ldap:Enabled"] = "false" };
if (configDbConnectionString is not null)
config["ConnectionStrings:ConfigDb"] = configDbConnectionString;
builder.Configuration.AddInMemoryCollection(config);
// (1) hasAdmin block.
if (hasAdmin)
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
// (2) unconditional for every role shape.
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(
builder.Configuration, LdapOptions.SectionName);
// (3) hasDriver block's TryAdd registrations — runs AFTER (1), never before it.
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
var app = builder.Build();
_apps.Add(app);
return app.Services;
}
[Fact]
public void FusedNode_RealLdapGroupRoleMappingService_WinsOverTheDriverBlockTryAdd()
{
// The Task 1b review gap: on a fused admin+driver node, AddOtOpcUaConfigDb's plain AddScoped
// registers the real EF-backed service BEFORE the hasDriver block's TryAddScoped runs, so the
// TryAdd is a no-op and the real service wins. If AddOtOpcUaConfigDb's registration were ever
// switched to a TryAdd, or the two blocks reordered, this flips to NullLdapGroupRoleMappingService
// and every DB-backed role grant central relies on silently stops applying — with no other test
// catching it.
var sp = BuildRoleMappingGraph(
hasAdmin: true,
configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");
using var scope = sp.CreateScope();
scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
.ShouldBeOfType<LdapGroupRoleMappingService>();
}
[Fact]
public void DriverOnlyNode_LdapGroupRoleMapping_ResolvesTheNullImpl_AndNoConfigDbFactory()
{
// The other half of the same pin: with no hasAdmin block ever run, the driver block's TryAdd
// is the ONLY registration, so it wins by default — no ConfigDb, no real EF service reachable.
var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);
sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldBeNull();
using var scope = sp.CreateScope();
scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
.ShouldBeOfType<NullLdapGroupRoleMappingService>();
}
[Fact]
public async Task DriverOnlyNode_IGroupRoleMapper_ResolvesInScope_AndMapsWithoutThrowing()
{
// Task 1b fixed a lazy auth-path break: IGroupRoleMapper<string> depends on
// ILdapGroupRoleMappingService, which a driver-only node never registered before that fix —
// the DI graph built fine (nothing eager touches it), and the break surfaced only on the first
// SCOPED resolve + call, deep in the OPC UA data-plane authenticator. A bare Build() cannot
// catch that class of bug; this test resolves in a scope and calls MapAsync, mirroring the
// real call site.
var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);
using var scope = sp.CreateScope();
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
mapper.ShouldBeOfType<OtOpcUaGroupRoleMapper>();
scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
.ShouldBeOfType<NullLdapGroupRoleMappingService>();
GroupRoleMapping<string> result = null!;
await Should.NotThrowAsync(async () => result = await mapper.MapAsync(["some-group"], CancellationToken.None));
result.ShouldNotBeNull();
result.Scope.ShouldBeNull();
}
public void Dispose()
{
foreach (var app in _apps)
((IDisposable)app).Dispose();
}
}
@@ -46,7 +46,7 @@ public sealed class LocalDbSetupTests : IDisposable
} }
[Fact] [Fact]
public void OnReady_RegistersExactlyTheThreeReplicatedTables() public void OnReady_RegistersExactlyTheFourReplicatedTables()
{ {
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call // Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
// (that table then never replicates). "No more" catches an accidental registration — // (that table then never replicates). "No more" catches an accidental registration —
@@ -54,7 +54,7 @@ public sealed class LocalDbSetupTests : IDisposable
var db = BuildDb(); var db = BuildDb();
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]); .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
} }
[Fact] [Fact]
@@ -89,6 +89,16 @@ public sealed class LocalDbSetupTests : IDisposable
db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]); db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
} }
[Fact]
public void AlarmConditionState_PkIsTheScriptedAlarmId()
{
// One condition-state row per alarm identity. The pair's two nodes converge on the same row
// under LWW rather than each keeping its own, and Save is an unconditional upsert onto it.
var db = BuildDb();
db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
}
[Fact] [Fact]
public async Task AlarmSfEventRows_EnterTheOplog() public async Task AlarmSfEventRows_EnterTheOplog()
{ {
@@ -11,7 +11,9 @@ using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.Observability;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -95,7 +97,7 @@ public sealed class LocalDbWiringTests : IDisposable
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs // Exact set, both directions — a dropped registration replicates nothing; an extra one costs
// three triggers and oplog volume on every write. // three triggers and oplog volume on every write.
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]); .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
} }
[Fact] [Fact]
@@ -107,6 +109,18 @@ public sealed class LocalDbWiringTests : IDisposable
.ShouldBeOfType<LocalDbDeploymentArtifactCache>(); .ShouldBeOfType<LocalDbDeploymentArtifactCache>();
} }
[Fact]
public void DriverGraph_AlarmStateStoreResolvesToTheLocalDbBackedImplementation()
{
// Phase 4: scripted-alarm condition state is served from the replicated LocalDb store on a
// driver node. A dropped registration would silently fall the actor back to the EF store (or,
// DB-less, skip the alarm host) — this pins the LocalDb store as the resolved implementation.
var sp = BuildDriverGraph();
sp.GetService<IAlarmStateStore>()
.ShouldBeOfType<LocalDbAlarmConditionStateStore>();
}
[Fact] [Fact]
public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer() public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer()
{ {
@@ -142,6 +156,8 @@ public sealed class LocalDbWiringTests : IDisposable
sp.GetService<ILocalDb>().ShouldBeNull(); sp.GetService<ILocalDb>().ShouldBeNull();
sp.GetService<IDeploymentArtifactCache>().ShouldBeNull(); sp.GetService<IDeploymentArtifactCache>().ShouldBeNull();
// Phase 4: an admin-only node never registers the LocalDb alarm store either.
sp.GetService<IAlarmStateStore>().ShouldBeNull();
// The unconditionally-registered health check must still resolve and construct — it is meant // The unconditionally-registered health check must still resolve and construct — it is meant
// to no-op to Healthy when LocalDb is absent, not to fail because ISyncStatus is missing. // to no-op to Healthy when LocalDb is absent, not to fail because ISyncStatus is missing.
@@ -0,0 +1,274 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Per-cluster mesh Phase 4 — a driver-only node runs with NO ConfigDb: <c>dbFactory</c> is null,
/// the redundant SQL ack-writes no-op (central persists the ack from the <c>ApplyAck</c>), and
/// scripted-alarm condition state is served from the replicated LocalDb store instead of the
/// ConfigDb-backed EF store. These tests drive a <c>FetchAndCache</c> actor with a null
/// <c>dbFactory</c> — the shape a real driver-only node boots in.
/// </summary>
public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"otopcua-dbless-{Guid.NewGuid():N}.db");
private readonly ServiceProvider _localDbProvider;
private readonly ILocalDb _localDb;
public DriverHostActorDbLessTests()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
.Build();
_localDbProvider = new ServiceCollection()
.AddZbLocalDb(configuration, db =>
{
using (var connection = db.CreateConnection())
{
AlarmConditionStateSchema.Apply(connection);
}
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
})
.BuildServiceProvider();
_localDb = _localDbProvider.GetRequiredService<ILocalDb>();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing) return;
_localDbProvider.Dispose();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
}
}
/// <summary>Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in.</summary>
private static byte[] ArtifactBytes() =>
JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });
private LocalDbAlarmConditionStateStore NewAlarmStore()
=> new(_localDb, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
private static ScriptRootLogger NewScriptRootLogger()
=> new(new LoggerConfiguration().CreateLogger());
/// <summary>Resolves the named child of <paramref name="parent"/>, or null when it was not spawned.
/// Safe to call once the parent has replied to an Identify — PreStart (and thus the spawn decision)
/// completes before any message is processed.</summary>
private IActorRef? ResolveChild(IActorRef parent, string name)
{
var id = Sys.ActorSelection(parent.Path / name)
.Ask<ActorIdentity>(new Identify(name), TimeSpan.FromSeconds(3))
.GetAwaiter().GetResult();
return id.Subject;
}
/// <summary>Blocks until the actor has processed a message — proving PreStart has run.</summary>
private void AwaitStarted(IActorRef actor)
{
var id = actor.Ask<ActorIdentity>(new Identify("started"), TimeSpan.FromSeconds(5))
.GetAwaiter().GetResult();
id.Subject.ShouldBe(actor);
}
/// <summary>
/// Task 4 — a full dispatch on a DB-less actor (null <c>dbFactory</c>, FetchAndCache, LocalDb
/// alarm store) applies and acks Applied without a NullReferenceException. The redundant SQL
/// ack-writes are guarded to no-ops; central is the ack system of record.
/// </summary>
[Fact]
public void DbLessDispatch_AppliesAndAcks_WithNoConfigDb()
{
var cache = new RecordingArtifactCache();
var fetcher = new RecordingFetcher(ArtifactBytes());
var deploymentId = DeploymentId.NewId();
var publish = CreateTestProbe();
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
dbFactory: null,
TestNode,
coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref,
scriptRootLogger: NewScriptRootLogger(),
deploymentArtifactCache: cache,
fetchAndCacheMode: true,
artifactFetcher: fetcher,
alarmStateStore: NewAlarmStore()));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
fetcher.Calls.ShouldBe(1);
}
/// <summary>
/// Task 6 — a DB-less actor with a LocalDb alarm store spawns its ScriptedAlarm host (the store
/// is what makes the spawn possible without a ConfigDb).
/// </summary>
[Fact]
public void DbLessNode_WithLocalDbStore_SpawnsScriptedAlarmHost()
{
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
dbFactory: null,
TestNode,
opcUaPublishActor: publish.Ref,
scriptRootLogger: NewScriptRootLogger(),
fetchAndCacheMode: true,
artifactFetcher: new RecordingFetcher(ArtifactBytes()),
alarmStateStore: NewAlarmStore()));
AwaitStarted(actor);
ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull();
}
/// <summary>
/// Task 6 — with neither a LocalDb store NOR a ConfigDb factory there is nowhere to persist
/// condition state, so the ScriptedAlarm host is skipped (not spawned against a null store).
/// </summary>
[Fact]
public void DbLessNode_WithNoStoreAndNoDbFactory_SkipsScriptedAlarmHost()
{
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
dbFactory: null,
TestNode,
opcUaPublishActor: publish.Ref,
scriptRootLogger: NewScriptRootLogger(),
fetchAndCacheMode: true,
artifactFetcher: new RecordingFetcher(ArtifactBytes()),
alarmStateStore: null));
AwaitStarted(actor);
ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
}
/// <summary>
/// Phase 4 Task 9 — the ConfigDb-backed EF fallback is retired. A DB-backed actor (non-null
/// <c>dbFactory</c>) with no alarm store now SKIPS the ScriptedAlarm host: the wired
/// <c>IAlarmStateStore</c> is the only source of condition-state persistence, and there is no
/// longer an EF store constructed over <c>dbFactory</c>.
/// </summary>
[Fact]
public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost()
{
var publish = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
dbFactory: NewInMemoryDbFactory(),
TestNode,
opcUaPublishActor: publish.Ref,
scriptRootLogger: NewScriptRootLogger(),
alarmStateStore: null));
AwaitStarted(actor);
ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
}
/// <summary>
/// Focused proof the LocalDb alarm store round-trips condition state with NO ConfigDb in play.
/// </summary>
[Fact]
public async Task LocalDbAlarmStore_RoundTripsConditionState_WithoutConfigDb()
{
var store = NewAlarmStore();
var t = new DateTime(2026, 07, 22, 08, 00, 00, DateTimeKind.Utc);
var state = new AlarmConditionState(
AlarmId: "dbless-alarm",
Enabled: AlarmEnabledState.Enabled,
Active: AlarmActiveState.Inactive,
Acked: AlarmAckedState.Acknowledged,
Confirmed: AlarmConfirmedState.Unconfirmed,
Shelving: ShelvingState.Unshelved,
LastTransitionUtc: t,
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: t,
LastAckUser: "op",
LastAckComment: "ack",
LastConfirmUtc: null,
LastConfirmUser: null,
LastConfirmComment: null,
Comments: System.Collections.Immutable.ImmutableList<AlarmComment>.Empty);
await store.SaveAsync(state, CancellationToken.None);
var loaded = await store.LoadAsync("dbless-alarm", CancellationToken.None);
loaded.ShouldNotBeNull();
loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
loaded.LastAckUser.ShouldBe("op");
}
/// <summary>Records fetch calls and returns canned bytes (or null for "no answer").</summary>
private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(bytes);
}
}
/// <summary>Records every store; GetCurrent* return null (no idempotency shortcut in these tests).</summary>
private sealed class RecordingArtifactCache : IDeploymentArtifactCache
{
private readonly Lock _gate = new();
private readonly List<StoreCall> _stores = [];
public IReadOnlyList<StoreCall> Stores
{
get { lock (_gate) { return _stores.ToArray(); } }
}
public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
byte[] artifact, CancellationToken ct = default)
{
lock (_gate) { _stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact)); }
return Task.CompletedTask;
}
public Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
internal sealed record StoreCall(
string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact);
}
}
@@ -110,6 +110,97 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
} }
/// <summary>
/// mesh-phase4 — the wipe-prevention test. A driver-only node has a NULL dbFactory (ConfigDb is
/// admin-only now) but a WIRED applier. A RebuildAddressSpace carrying the artifact bytes in hand
/// (from the fetch/cache) must drive the REAL diff-and-apply (the equipment folder is materialised)
/// and must NOT take the raw-sink <c>RebuildAddressSpace()</c> fallback, which wipes the address
/// space without re-materialising. Before the guard split, a null dbFactory routed straight to that
/// fallback and the applier never ran — a FetchAndCache node deployed green but served EMPTY.
/// </summary>
[Fact]
public void Rebuild_with_null_factory_but_wired_applier_diff_applies_from_in_hand_bytes()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var artifact = BuildNamedEquipmentArtifact(("eq-1", "Pump-1"));
// Driver-only node: null dbFactory, wired applier.
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: null, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact));
// The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised…
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2));
// …and the address-space-wiping raw-sink fallback was NOT taken.
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>
/// mesh-phase4 / #485 negative — a driver-only node (null dbFactory, wired applier) that gets a
/// RebuildAddressSpace with NO artifact bytes has no way to obtain a config (there is no ConfigDb to
/// read). The artifact source yields <c>Array.Empty&lt;byte&gt;()</c>, which the #485 zero-length
/// guard treats as "no answer": the rebuild is abandoned WITHOUT touching the served address space —
/// the applier never runs and the raw-sink wipe fallback is never taken. Last-known-good stands.
/// </summary>
[Fact]
public void Rebuild_with_null_factory_and_no_artifact_abandons_without_wiping()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: null, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
Thread.Sleep(200);
sink.RebuildCalls.ShouldBe(0); // the raw-sink wipe fallback was NOT taken
sink.Calls.ShouldBeEmpty(); // the applier's diff-and-apply never ran
}
/// <summary>
/// mesh-phase4 regression (dev seam) — a node with NO applier at all still falls back to the raw-sink
/// rebuild, and now the fallback is keyed on the applier ALONE: even WITH a dbFactory wired, a null
/// applier takes the F10b/dev fallback. Proves the guard split did not change the no-applier seam.
/// </summary>
[Fact]
public void Rebuild_with_null_applier_falls_back_to_raw_sink_even_with_dbFactory()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: db, applier: null));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// mesh-phase4 regression (Direct mode) — a DB-backed node (dbFactory wired, applier wired) with no
/// in-hand bytes but a DeploymentId still loads the artifact from the ConfigDb via <c>LoadArtifact</c>
/// and drives the applier. The guard split must leave the Direct path byte-identical.
/// </summary>
[Fact]
public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var dep = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2));
sink.RebuildCalls.ShouldBe(0);
}
/// <summary> /// <summary>
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must /// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
@@ -358,14 +449,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
: inner.CreateDbContext(); : inner.CreateDbContext();
} }
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning /// <summary>Build the artifact bytes for the given (equipmentId, name) rows under a shared line
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such /// WITHOUT sealing them into a deployment. This is what a driver-only / FetchAndCache node carries
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary> /// in <c>RebuildAddressSpace.Artifact</c> (no ConfigDb read), so tests can drive the in-hand-bytes
private static Guid SeedNamedEquipmentDeployment( /// path directly.</summary>
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, private static byte[] BuildNamedEquipmentArtifact(params (string Id, string Name)[] equipment) =>
params (string Id, string Name)[] equipment) JsonSerializer.SerializeToUtf8Bytes(new
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
@@ -383,6 +472,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
ScriptedAlarms = Array.Empty<object>(), ScriptedAlarms = Array.Empty<object>(),
}); });
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary>
private static Guid SeedNamedEquipmentDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
params (string Id, string Name)[] equipment)
{
var artifact = BuildNamedEquipmentArtifact(equipment);
var id = Guid.NewGuid(); var id = Guid.NewGuid();
using var ctx = dbFactory.CreateDbContext(); using var ctx = dbFactory.CreateDbContext();
ctx.Deployments.Add(new Deployment ctx.Deployments.Add(new Deployment
@@ -580,6 +580,143 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2)); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2));
} }
// ── Per-cluster mesh Phase 4: DB-less driver-only nodes ─────────────────────────────────────
// A driver-only node has no ConfigDb (LocalDb is its config store), so there is no DB to probe.
// Such nodes run with dbHealthProbe: null + dbLess: true and must still publish the full
// survive-alone ServiceLevel (240 follower / 250 Primary) — DbReachable is treated as a
// constant true because the in-process LocalDb can never be "unreachable"; only snapshot
// staleness demotes.
/// <summary>DB-less, member Up, snapshot fresh, follower (non-Primary) → 240. No probe wired,
/// no DbHealthStatus ever arrives; the DB-less branch must still reach full service.</summary>
[Fact]
public void DbLess_healthy_follower_publishes_240()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("db-less-follower");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(30)));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
[Fact]
public void DbLess_healthy_primary_publishes_250()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("db-less-primary");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(30)));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Primary,
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
/// is derived from snapshot age ALONE (no DB-health term), so a DB-less node still demotes to 200
/// when it is running behind on config.</summary>
[Fact]
public void DbLess_stale_snapshot_publishes_200()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("db-less-stale");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(2)));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsDriverPrimary: false,
DateTime.UtcNow - TimeSpan.FromMinutes(1)),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less
/// branch, so a DB-less node that detaches still fails closed to 0. Goes healthy (250) first so
/// the transition down to 0 is observable (not dedup'd against the initial 0).</summary>
[Fact]
public void DbLess_detached_publishes_0()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("db-less-detached");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbLess: true,
staleWindow: TimeSpan.FromSeconds(30)));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Primary,
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Detached,
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
/// false) is unchanged — it still computes 240 via the calculator path. Proves the new DB-less
/// branch did not disturb the existing DB-backed seam.</summary>
[Fact]
public void DbBacked_node_unchanged_still_publishes_240()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("db-backed-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/> /// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
/// with a fixed status, so the calculator path is deterministic without a real timer.</summary> /// with a fixed status, so the calculator path is deterministic without a real timer.</summary>
private sealed class StubDbHealth : Akka.Actor.ReceiveActor private sealed class StubDbHealth : Akka.Actor.ReceiveActor
@@ -1,25 +1,76 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// <summary> /// <summary>
/// Round-trip + upsert + lifecycle coverage for <see cref="EfAlarmConditionStateStore"/>, /// Round-trip + upsert + lifecycle coverage for <see cref="LocalDbAlarmConditionStateStore"/>,
/// the EF-backed <see cref="IAlarmStateStore"/> over the <c>ScriptedAlarmState</c> table. /// the LocalDb-backed <see cref="IAlarmStateStore"/> over the replicated
/// <c>alarm_condition_state</c> table.
/// </summary> /// </summary>
public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase /// <remarks>
/// Ported verbatim from <c>EfAlarmConditionStateStoreTests</c> — the two stores share the
/// <see cref="IAlarmStateStore"/> contract, so the assertions transfer unchanged and prove
/// parity. Only store construction differs: this suite drives a real temp-file
/// <see cref="ILocalDb"/> (the library has no in-memory mode, and a bare
/// <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture triggers
/// call), whereas the EF suite used an in-memory DbContext factory.
/// </remarks>
public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
{ {
private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> db) private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-state-{Guid.NewGuid():N}.db");
private readonly ServiceProvider _provider;
private readonly ILocalDb _db;
public LocalDbAlarmConditionStateStoreTests()
{ {
db = NewInMemoryDbFactory(); var configuration = new ConfigurationBuilder()
return new EfAlarmConditionStateStore(db, NullLogger<EfAlarmConditionStateStore>.Instance); .AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
.Build();
_provider = new ServiceCollection()
.AddZbLocalDb(configuration, db =>
{
using (var connection = db.CreateConnection())
{
AlarmConditionStateSchema.Apply(connection);
}
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
})
.BuildServiceProvider();
_db = _provider.GetRequiredService<ILocalDb>();
} }
public void Dispose()
{
_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 LocalDbAlarmConditionStateStore NewStore()
=> new(_db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
private static AlarmConditionState Sample( private static AlarmConditionState Sample(
string alarmId, string alarmId,
AlarmActiveState active = AlarmActiveState.Inactive, AlarmActiveState active = AlarmActiveState.Inactive,
@@ -50,7 +101,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields() public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields()
{ {
var store = NewStore(out _); var store = NewStore();
var state = Sample("alarm-1"); var state = Sample("alarm-1");
await store.SaveAsync(state, CancellationToken.None); await store.SaveAsync(state, CancellationToken.None);
@@ -70,13 +121,52 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc); loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
loaded.LastConfirmUser.ShouldBe("bob"); loaded.LastConfirmUser.ShouldBe("bob");
loaded.LastConfirmComment.ShouldBe("confirm-comment"); loaded.LastConfirmComment.ShouldBe("confirm-comment");
// Shouldly's DateTime ShouldBe compares ticks and ignores DateTimeKind, so an explicit Kind
// assertion is the only guard on the Utc-kind contract the round-trip ("O") format encodes.
loaded.LastTransitionUtc.Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>Unset audit fields (LastAckUtc/User, LastConfirmUtc/User) round-trip back as null.</summary>
[Fact]
public async Task Null_audit_fields_round_trip_as_null()
{
var store = NewStore();
var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
var state = new AlarmConditionState(
AlarmId: "alarm-null-audit",
Enabled: AlarmEnabledState.Enabled,
Active: AlarmActiveState.Inactive,
Acked: AlarmAckedState.Unacknowledged,
Confirmed: AlarmConfirmedState.Unconfirmed,
Shelving: ShelvingState.Unshelved,
LastTransitionUtc: t,
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: null,
LastAckUser: null,
LastAckComment: null,
LastConfirmUtc: null,
LastConfirmUser: null,
LastConfirmComment: null,
Comments: ImmutableList<AlarmComment>.Empty);
await store.SaveAsync(state, CancellationToken.None);
var loaded = await store.LoadAsync("alarm-null-audit", CancellationToken.None);
loaded.ShouldNotBeNull();
loaded.LastAckUtc.ShouldBeNull();
loaded.LastAckUser.ShouldBeNull();
loaded.LastAckComment.ShouldBeNull();
loaded.LastConfirmUtc.ShouldBeNull();
loaded.LastConfirmUser.ShouldBeNull();
loaded.LastConfirmComment.ShouldBeNull();
} }
/// <summary>Loading an id that was never saved returns null.</summary> /// <summary>Loading an id that was never saved returns null.</summary>
[Fact] [Fact]
public async Task LoadAsync_for_unknown_id_returns_null() public async Task LoadAsync_for_unknown_id_returns_null()
{ {
var store = NewStore(out _); var store = NewStore();
var loaded = await store.LoadAsync("never-saved", CancellationToken.None); var loaded = await store.LoadAsync("never-saved", CancellationToken.None);
@@ -87,7 +177,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Active_is_ignored_on_load_and_defaults_to_Inactive() public async Task Active_is_ignored_on_load_and_defaults_to_Inactive()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None); await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None);
var loaded = await store.LoadAsync("alarm-active", CancellationToken.None); var loaded = await store.LoadAsync("alarm-active", CancellationToken.None);
@@ -100,7 +190,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Derived_timestamps_default_to_null_on_load() public async Task Derived_timestamps_default_to_null_on_load()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None); await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None);
var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None); var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None);
@@ -114,7 +204,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Unshelved_round_trips() public async Task Unshelved_round_trips()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync( await store.SaveAsync(
Sample("alarm-unshelved", shelving: ShelvingState.Unshelved), Sample("alarm-unshelved", shelving: ShelvingState.Unshelved),
CancellationToken.None); CancellationToken.None);
@@ -130,7 +220,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task OneShot_shelving_round_trips() public async Task OneShot_shelving_round_trips()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync( await store.SaveAsync(
Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)), Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)),
CancellationToken.None); CancellationToken.None);
@@ -146,7 +236,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Comments_round_trip_all_fields() public async Task Comments_round_trip_all_fields()
{ {
var store = NewStore(out _); var store = NewStore();
var c1 = new AlarmComment( var c1 = new AlarmComment(
new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"); new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump");
var c2 = new AlarmComment( var c2 = new AlarmComment(
@@ -172,7 +262,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Empty_comments_round_trip() public async Task Empty_comments_round_trip()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync( await store.SaveAsync(
Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty), Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
CancellationToken.None); CancellationToken.None);
@@ -183,20 +273,69 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
loaded.Comments.ShouldBeEmpty(); loaded.Comments.ShouldBeEmpty();
} }
/// <summary>An empty comment list persists as the literal <c>"[]"</c> in the column.</summary>
[Fact]
public async Task Empty_comments_persist_as_bracket_pair()
{
var store = NewStore();
await store.SaveAsync(
Sample("alarm-empty-literal", comments: ImmutableList<AlarmComment>.Empty),
CancellationToken.None);
using var conn = _db.CreateConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-empty-literal'";
var json = (string?)cmd.ExecuteScalar();
json.ShouldBe("[]");
}
/// <summary>An unknown enabled string loads back as Enabled (safe default).</summary>
[Fact]
public async Task Unknown_enum_strings_map_to_safe_defaults()
{
// Write raw rows carrying garbage enum strings to prove the load-side mappers coerce them.
using (var conn = _db.CreateConnection())
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
comments_json, updated_at_utc)
VALUES
('garbage', 'nonsense', 'nonsense', 'nonsense', 'nonsense', '[]', '2026-06-10T12:00:00.0000000Z')
""";
cmd.ExecuteNonQuery();
}
var loaded = await NewStore().LoadAsync("garbage", CancellationToken.None);
loaded.ShouldNotBeNull();
loaded.Enabled.ShouldBe(AlarmEnabledState.Enabled); // unknown → Enabled
loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); // unknown → Unacknowledged
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); // unknown → Unconfirmed
loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); // unknown → Unshelved
}
/// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary> /// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
[Fact] [Fact]
public async Task SaveAsync_twice_upserts_latest() public async Task SaveAsync_twice_upserts_latest()
{ {
var store = NewStore(out var db); var store = NewStore();
var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled }; var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled };
var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled }; var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled };
await store.SaveAsync(first, CancellationToken.None); await store.SaveAsync(first, CancellationToken.None);
await store.SaveAsync(second, CancellationToken.None); await store.SaveAsync(second, CancellationToken.None);
using (var ctx = db.CreateDbContext()) using (var conn = _db.CreateConnection())
using (var cmd = conn.CreateCommand())
{ {
ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1); cmd.CommandText =
"SELECT COUNT(*) FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-upsert'";
((long)cmd.ExecuteScalar()!).ShouldBe(1);
} }
var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None); var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None);
@@ -209,7 +348,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task LoadAllAsync_returns_all_saved_states() public async Task LoadAllAsync_returns_all_saved_states()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync(Sample("a-1"), CancellationToken.None); await store.SaveAsync(Sample("a-1"), CancellationToken.None);
await store.SaveAsync(Sample("a-2"), CancellationToken.None); await store.SaveAsync(Sample("a-2"), CancellationToken.None);
await store.SaveAsync(Sample("a-3"), CancellationToken.None); await store.SaveAsync(Sample("a-3"), CancellationToken.None);
@@ -223,7 +362,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null() public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null()
{ {
var store = NewStore(out _); var store = NewStore();
await store.SaveAsync(Sample("keep"), CancellationToken.None); await store.SaveAsync(Sample("keep"), CancellationToken.None);
await store.SaveAsync(Sample("drop"), CancellationToken.None); await store.SaveAsync(Sample("drop"), CancellationToken.None);
@@ -237,7 +376,7 @@ public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task RemoveAsync_for_unknown_id_is_noop() public async Task RemoveAsync_for_unknown_id_is_noop()
{ {
var store = NewStore(out _); var store = NewStore();
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None)); await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
} }
@@ -72,6 +72,27 @@ public sealed class OtOpcUaGroupRoleMapperTests
result.Roles.ShouldBeEmpty(); result.Roles.ShouldBeEmpty();
} }
[Fact]
public async Task Driver_only_node_falls_back_to_appsettings_baseline_via_null_mapping_service()
{
// Simulates a driver-only node: no ConfigDb, so ILdapGroupRoleMappingService is bound to
// NullLdapGroupRoleMappingService instead of the EF-backed one. MapAsync must still resolve
// roles from Security:Ldap:GroupToRole alone, and must not throw for lack of a DB.
var options = Options.Create(new LdapOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["operators"] = "WriteOperate",
},
});
var mapper = new OtOpcUaGroupRoleMapper(options, new NullLdapGroupRoleMappingService());
var result = await mapper.MapAsync(["operators", "unmapped-group"], CancellationToken.None);
result.Roles.ShouldBe(["WriteOperate"]);
result.Scope.ShouldBeNull();
}
[Fact] [Fact]
public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs() public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs()
{ {
@@ -116,3 +137,49 @@ public sealed class OtOpcUaGroupRoleMapperTests
=> throw new NotSupportedException(); => throw new NotSupportedException();
} }
} }
/// <summary>
/// Proves <see cref="NullLdapGroupRoleMappingService"/> — the driver-only-node stand-in for the
/// EF-backed <c>ILdapGroupRoleMappingService</c> — always reports "no DB grants" rather than
/// throwing on the hot sign-in path, while its control-plane write surface (never called
/// without an AdminUI) fails loudly instead of silently no-opping.
/// </summary>
public sealed class NullLdapGroupRoleMappingServiceTests
{
[Fact]
public async Task GetByGroupsAsync_returns_empty()
{
var sut = new NullLdapGroupRoleMappingService();
var result = await sut.GetByGroupsAsync(["any-group"], CancellationToken.None);
result.ShouldBeEmpty();
}
[Fact]
public async Task ListAllAsync_returns_empty()
{
var sut = new NullLdapGroupRoleMappingService();
var result = await sut.ListAllAsync(CancellationToken.None);
result.ShouldBeEmpty();
}
[Fact]
public async Task CreateAsync_throws_NotSupportedException()
{
var sut = new NullLdapGroupRoleMappingService();
var row = new LdapGroupRoleMapping { LdapGroup = "g", Role = AdminRole.Administrator, IsSystemWide = true };
await Should.ThrowAsync<NotSupportedException>(() => sut.CreateAsync(row, CancellationToken.None));
}
[Fact]
public async Task DeleteAsync_throws_NotSupportedException()
{
var sut = new NullLdapGroupRoleMappingService();
await Should.ThrowAsync<NotSupportedException>(() => sut.DeleteAsync(Guid.NewGuid(), CancellationToken.None));
}
}