Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d218282cd6 | |||
| 5ebf9876a5 | |||
| 7808faa7f6 | |||
| 689fc06e85 | |||
| ef41a43102 | |||
| 5627d325eb | |||
| e4397aa84e | |||
| 5aad1e33de | |||
| b0c031f09f | |||
| c7f5b9cfa3 | |||
| 4b0be1335e | |||
| 5fdb5a5a5e | |||
| 09df440675 | |||
| f587e7972e | |||
| 232bff5df7 | |||
| c957db52e2 | |||
| ebc5fc6742 | |||
| 41c36064dd | |||
| c6a9f93a0c | |||
| 9137cb41eb | |||
| ce9fa07ff8 | |||
| 4b2f0e6e15 | |||
| c08762db1b | |||
| c834d89ae7 | |||
| 7676ab93c4 | |||
| afa5be713a | |||
| 5a21be0a62 | |||
| b350fba233 | |||
| abe10dbbd7 | |||
| a27eff3298 | |||
| 1becf59168 | |||
| a38a52b831 | |||
| 2bae1b4c9f | |||
| e771a11a30 | |||
| b9ddf20edd | |||
| 6aff9a8332 | |||
| 3d29be834e | |||
| 3b65c24bfb | |||
| 44255fcb91 | |||
| 42f8550716 | |||
| 1adaa2fc01 | |||
| a5eac3ec78 | |||
| be87ddeb0b | |||
| 5f72ff851d | |||
| c878fbbd03 | |||
| 2254ae3dea | |||
| 1ccc237cb6 | |||
| b3d1a26f38 | |||
| 3336ec08c7 | |||
| e27c19c49d | |||
| f347762350 | |||
| 2cae4c8f01 | |||
| 043e237dba | |||
| 8c5e2be92e | |||
| 6dda0549e2 | |||
| db751d12a5 | |||
| f6a3c31b60 | |||
| e08b6b0e69 | |||
| 50426d4790 | |||
| 7339a4af07 | |||
| 872cf7e37a | |||
| f1534920de | |||
| 9bb237b794 | |||
| 1424a21419 | |||
| ce383df39a | |||
| a0be76b5f0 | |||
| 73d8439412 | |||
| 8843418c54 | |||
| 772d3a5f34 | |||
| ec6598ceae | |||
| b4b378c5cd | |||
| e41ffe7655 | |||
| 51022c3952 | |||
| 21eb81c915 | |||
| b95efb0b28 | |||
| 1badc10445 | |||
| e6607ad5ab | |||
| b8208b3312 | |||
| e959423323 | |||
| 3cf3576c75 | |||
| 90e52a4415 | |||
| 1e9454582e | |||
| 8ebc712eff | |||
| 9d09523675 | |||
| 2e0743ad25 | |||
| 77c39bf02d | |||
| 3efcf8014b | |||
| 8b0b627f81 | |||
| e95615cef3 | |||
| 0dbdee003b | |||
| 4807aa7edd | |||
| 5534698d70 | |||
| f4f3e17e3e | |||
| a1a56e22bb | |||
| f8f3b82ed6 |
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
|
||||
gRPC session. The gateway owns the COM apartment + STA pump
|
||||
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
||||
exclusively.
|
||||
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
|
||||
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
|
||||
reads/writes/subscriptions are translated to that reference for MXAccess.
|
||||
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
|
||||
(see below). Galaxy tags are bound by `TagConfig.FullName`
|
||||
(`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
|
||||
reference for MXAccess.
|
||||
|
||||
### v3 OPC UA Address Space (Batch 4): dual namespace
|
||||
|
||||
The server exposes **two OPC UA namespaces** (replacing the single
|
||||
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
|
||||
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
|
||||
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
|
||||
|
||||
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|
||||
|---|---|---|---|
|
||||
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||
|
||||
- **Single source, fanned to both.** Every device value has exactly one source —
|
||||
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
|
||||
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
|
||||
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
|
||||
to the raw NodeId AND every referencing UNS NodeId with identical
|
||||
value/quality/timestamp — the mux key stays single (RawPath).
|
||||
- **Writes via either NodeId** route to the same driver ref under the same
|
||||
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
|
||||
closed); a failed device write reverts both NodeIds via the shared fan-out
|
||||
(write-outcome self-correction).
|
||||
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
|
||||
`ConditionId = RawPath`.
|
||||
- **Historian dual-registration.** Both NodeIds register the **same** historian
|
||||
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
|
||||
through either NodeId.
|
||||
|
||||
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
|
||||
the single-namespace `EquipmentTags` materialization path are gone. See
|
||||
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
|
||||
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
|
||||
|
||||
### Key Concept: Tag Name and FullName
|
||||
|
||||
@@ -183,6 +219,24 @@ The server supports configurable OPC UA transport security via the `OpcUa:Enable
|
||||
|
||||
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and the same mxaccessgw (under distinct `MxAccess.ClientName` values) but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
|
||||
|
||||
## LocalDb pair-local config cache (Phase 1)
|
||||
|
||||
Every **driver-role** node keeps a consolidated `ZB.MOM.WW.LocalDb` SQLite database (the retired
|
||||
LiteDB `LocalCache` subsystem was deleted as superseded). Phase-1 scope: it caches the
|
||||
**deployed-configuration artifact** (chunked, SHA-256-verified, newest-2 retention per cluster) so a
|
||||
driver node can **boot from cache when central SQL Server is unreachable** — `DriverHostActor` writes
|
||||
the cache after each successful apply (`IDeploymentArtifactCache` → `LocalDbDeploymentArtifactCache`,
|
||||
in `Runtime/Deployment/`) and reads it as a boot fallback, flagging running-from-cache. Wired by
|
||||
`AddOtOpcUaLocalDb` in the `hasDriver` branch only (`Host/Configuration/LocalDbRegistration.cs` +
|
||||
`LocalDbSetup.OnReady`, whose DDL→`RegisterReplicated` order is load-bearing); **admin-only nodes
|
||||
never register it.** The cache can optionally replicate to the node's redundant pair peer over the
|
||||
library's gRPC sync — **default-OFF, fail-closed bearer auth** (`LocalDbSyncAuthInterceptor`), gated
|
||||
on `LocalDb:SyncListenPort` (a dedicated Kestrel h2c listener) + `LocalDb:Replication:*`. On the
|
||||
docker-dev rig the **site-a pair** has replication on; central + site-b stay default-OFF (site-b is
|
||||
the pin). ApiKey via `${secret:}`/env, never committed (the rig's dev key is the committed-dev-secret
|
||||
exception). See `docs/Configuration.md` (`LocalDb` section), `docs/Redundancy.md` (pair-local config
|
||||
cache), and the runbook `docs/operations/2026-07-20-localdb-pair-replication.md`.
|
||||
|
||||
## 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.
|
||||
@@ -219,6 +273,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
|
||||
|
||||
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
|
||||
|
||||
**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces** — `https://zb.com/otopcua/raw` (Raw device tree, `s=<RawPath>`) + `https://zb.com/otopcua/uns` (UNS tree, `s=<Area>/<Line>/<Equipment>/<EffectiveName>`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||
|
||||
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
||||
|
||||
## Scripting / Script Editor
|
||||
@@ -359,11 +415,16 @@ The `AlarmHistorian` section's old Wonderware connection keys (`Host`/`Port`/`Us
|
||||
were pruned — remove them; the SQLite knobs are retained and the downstream connection now comes from
|
||||
`ServerHistorian`. See `docs/Historian.md` for the full guide.
|
||||
|
||||
### KNOWN LIMITATION 1 — live-validation gate (do before merging/trusting the cutover)
|
||||
### Live-validation gate — PASSED (was "KNOWN LIMITATION 1")
|
||||
|
||||
The cutover is code-complete but **must be live-validated against a real gateway** (VPN to
|
||||
`wonder-sql-vd03`, gateway running the prerequisites above) before it is merged or trusted. Run the
|
||||
env-gated suite:
|
||||
**No longer a limitation.** The cutover was live-validated against the real gateway on
|
||||
`wonder-sql-vd03`: read ✅, write-persist ✅, alarm-send ✅, alarm-readback skipped as a *confirmed
|
||||
protocol limitation* (the captured `CM_EVENT` wire never carries `SourceName`, so the gateway cannot
|
||||
populate `Source_Object` — not a fixable bug). Evidence:
|
||||
`docs/plans/2026-06-27-otopcua-historian-followups.md`.
|
||||
|
||||
Kept here because it is the recipe to **re-run** the gate after any gateway or backend change (VPN to
|
||||
`wonder-sql-vd03`, gateway running the prerequisites above):
|
||||
|
||||
```bash
|
||||
export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222 # absolute gateway URI; absent ⇒ all live tests skip
|
||||
@@ -378,7 +439,7 @@ dotnet test --filter "Category=LiveIntegration"
|
||||
The live suite **skips cleanly** when these env vars are absent (safe to run offline on macOS). It is the
|
||||
gate the operator runs on the VPN before trusting the cutover.
|
||||
|
||||
### KNOWN LIMITATION 2 — continuous-historization value-capture wired; live end-to-end verification still pending
|
||||
### KNOWN LIMITATION 2 — continuous-historization value-capture wired; live end-to-end verification still pending (issue #491)
|
||||
|
||||
The `ContinuousHistorizationRecorder` is fully wired (actor + FasterLog outbox + gateway value-writer +
|
||||
meters). It is spawned with an **empty *initial* ref set** (`Array.Empty<string>()` in
|
||||
@@ -392,4 +453,4 @@ deployed set of historized value tags from the first deploy onward (the feed is
|
||||
path is code-complete.** What remains is the **live gate**: an end-to-end verification against a real gateway
|
||||
(deploy → dependency-mux value delta → outbox append → `WriteLiveValues`) plus a restart-convergence test
|
||||
proving the recorder re-registers the deployed set after a process restart. Until that live verification runs,
|
||||
treat continuous value-capture as unproven-in-production rather than absent.
|
||||
treat continuous value-capture as unproven-in-production rather than absent. Tracked as **issue #491**.
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
|
||||
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Core.Api" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
<PackageVersion Include="MessagePack" Version="2.5.301" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||
@@ -122,6 +122,13 @@
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<!--
|
||||
LocalDb: embedded SQLite node-local cache + optional 2-node gRPC replication. The core
|
||||
package floors the native SQLitePCLRaw.lib.e_sqlite3 at 2.1.12, so consumers inherit the
|
||||
CVE-2025-6965-fixed native binary without needing the surgical pin below.
|
||||
-->
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
@@ -129,9 +136,13 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.5" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.5" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||
<package pattern="ZB.MOM.WW.Secrets" />
|
||||
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||
<package pattern="ZB.MOM.WW.LocalDb" />
|
||||
<package pattern="ZB.MOM.WW.LocalDb.*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -47,6 +47,29 @@
|
||||
|
||||
name: otopcua-dev
|
||||
|
||||
# ── Shared secret-replication env (all six host nodes) ──────────────────────────
|
||||
# Akka peer-to-peer clustered secret replication rides the existing `otopcua` Akka
|
||||
# mesh (DistributedPubSub topic `zb-mom-ww-secrets`) across all six host nodes. Two
|
||||
# hard requirements are wired here, identically, on every node:
|
||||
# 1. Secrets__Replication__Enabled=true → SecretsRegistration.AddOtOpcUaSecrets
|
||||
# switches from plain AddZbSecrets to AddZbSecretsAkkaReplication + the eager
|
||||
# SecretReplicationStarter hosted service, so every node joins anti-entropy.
|
||||
# 2. ZB_SECRETS_MASTER_KEY → the ONE shared 32-byte base64 KEK. Every node MUST
|
||||
# carry the SAME key or peers fail closed decrypting each other's rows
|
||||
# (kek_id mismatch). appsettings.json binds Secrets:MasterKey:Source=Environment
|
||||
# / EnvVarName=ZB_SECRETS_MASTER_KEY, so this env var IS the KEK on every node.
|
||||
# The KEK below is a committed DEV-ONLY default (matches the rig's zero-operator-step
|
||||
# philosophy, like the committed dev SQL password). Override for a fresh key with:
|
||||
# OTOPCUA_SECRETS_KEK=$(openssl rand -base64 32) docker compose ... up -d
|
||||
# NEVER reuse this key outside this local dev rig.
|
||||
# AnnounceInterval is shortened to 5s (default 30s) so anti-entropy convergence is
|
||||
# observable quickly during dev exercise. Each node keeps its own local SQLite store
|
||||
# (Secrets:SqlitePath=otopcua-secrets.db, per-container); replication syncs them.
|
||||
x-secrets-env: &secrets-env
|
||||
Secrets__Replication__Enabled: "true"
|
||||
Secrets__Replication__AnnounceInterval: "00:00:05"
|
||||
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
|
||||
|
||||
services:
|
||||
|
||||
sql:
|
||||
@@ -145,6 +168,7 @@ services:
|
||||
sql: { condition: service_healthy }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
@@ -200,8 +224,15 @@ services:
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# Consolidated pair-local config cache (ZB.MOM.WW.LocalDb). Every driver-role node
|
||||
# (central is admin,driver) gets one, on a per-node named volume so the cache survives
|
||||
# container recreates. The central pair leaves replication OFF (default) — only the
|
||||
# site-a pair replicates, as the enablement demo; site-b is the default-OFF pin.
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
ports:
|
||||
- "4840:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-central-1:/app/data
|
||||
|
||||
central-2:
|
||||
<<: *otopcua-host
|
||||
@@ -210,6 +241,7 @@ services:
|
||||
central-1: { condition: service_started }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
@@ -258,8 +290,12 @@ services:
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# Pair-local config cache; central pair leaves replication OFF (see central-1).
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
ports:
|
||||
- "4841:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-central-2:/app/data
|
||||
|
||||
# ── Site A cluster (2-node driver-only) ─────────────────────────────────────
|
||||
# Driver-only members of the single mesh, scoped to SITE-A by ClusterId. No UI,
|
||||
@@ -280,6 +316,7 @@ services:
|
||||
Cluster__PublicHostname: "site-a-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
||||
# mem_reservation are inherited from the *otopcua-host anchor.
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
@@ -287,8 +324,24 @@ services:
|
||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo.
|
||||
# site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer.
|
||||
# Setting SyncListenPort makes Program.cs add a dedicated Http2-only Kestrel listener and
|
||||
# re-bind the primary HTTP port (an explicit Listen* otherwise discards ASPNETCORE_URLS).
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
LocalDb__SyncListenPort: "9001"
|
||||
LocalDb__Replication__PeerAddress: "http://site-a-2:9001"
|
||||
# DEV-ONLY committed key (rig philosophy — like the SQL password + secrets KEK above). It
|
||||
# MUST be byte-identical on both nodes: the interceptor is fail-closed, so any mismatch
|
||||
# silently stops the pair converging. NEVER reuse this key outside this local dev rig.
|
||||
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
|
||||
# Row-count batching against gRPC's 4 MB message cap; artifact chunk rows are ≈171 KB, so
|
||||
# 16 × 171 KB ≈ 2.7 MB stays under the cap with headroom.
|
||||
LocalDb__Replication__MaxBatchSize: "16"
|
||||
ports:
|
||||
- "4842:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-site-a-1:/app/data
|
||||
|
||||
site-a-2:
|
||||
<<: *otopcua-host
|
||||
@@ -304,11 +357,22 @@ services:
|
||||
Cluster__PublicHostname: "site-a-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# site-a-2 is the passive half of the replicating pair: it binds the sync listener on 9001
|
||||
# but does NOT dial (no PeerAddress). The replication stream is bidirectional, so a-1's
|
||||
# single dial carries both directions. Same key + MaxBatchSize as a-1 (byte-identical key
|
||||
# is mandatory — the interceptor fail-closes on a mismatch).
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
LocalDb__SyncListenPort: "9001"
|
||||
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
|
||||
LocalDb__Replication__MaxBatchSize: "16"
|
||||
ports:
|
||||
- "4843:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-site-a-2:/app/data
|
||||
|
||||
# ── Site B cluster (2-node driver-only) ─────────────────────────────────────
|
||||
|
||||
@@ -326,11 +390,18 @@ services:
|
||||
Cluster__PublicHostname: "site-b-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# site-b is the default-OFF pin: it gets the pair-local cache but NO SyncListenPort and NO
|
||||
# replication config, so no sync listener binds and ISyncStatus stays disconnected/Healthy.
|
||||
# This is what the live gate's check 8 verifies — the cache works locally with replication off.
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
ports:
|
||||
- "4844:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-site-b-1:/app/data
|
||||
|
||||
site-b-2:
|
||||
<<: *otopcua-host
|
||||
@@ -346,11 +417,16 @@ services:
|
||||
Cluster__PublicHostname: "site-b-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# site-b default-OFF pin (see site-b-1): cache on, replication off.
|
||||
LocalDb__Path: "/app/data/otopcua-localdb.db"
|
||||
ports:
|
||||
- "4845:4840"
|
||||
volumes:
|
||||
- otopcua-localdb-site-b-2:/app/data
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.1
|
||||
@@ -371,3 +447,13 @@ services:
|
||||
volumes:
|
||||
# SQL Server data dir — persists the OtOpcUa ConfigDb across container recreates.
|
||||
otopcua-mssql-data:
|
||||
# Per-node pair-local config-cache (ZB.MOM.WW.LocalDb) files. One volume per node — the
|
||||
# cache is node-local, not shared; it persists the cached deployment artifact (and, on the
|
||||
# site-a pair, the replicated oplog state) across container recreates so a node can boot
|
||||
# from cache after a restart even with central SQL down.
|
||||
otopcua-localdb-central-1:
|
||||
otopcua-localdb-central-2:
|
||||
otopcua-localdb-site-a-1:
|
||||
otopcua-localdb-site-a-2:
|
||||
otopcua-localdb-site-b-1:
|
||||
otopcua-localdb-site-b-2:
|
||||
|
||||
@@ -24,6 +24,163 @@ condition — the dedup logic prefers the richer driver-native record
|
||||
because it carries the full operator + raise-time + category metadata
|
||||
that the value-driven path collapses.
|
||||
|
||||
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
|
||||
|
||||
Under the v3 dual-namespace address space, a native driver alarm is authored on a
|
||||
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
|
||||
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
|
||||
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
|
||||
same condition is wired as an **event notifier of every referencing equipment folder**
|
||||
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
|
||||
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||
`EnsureFolderIsEventNotifier(equipFolder)`.
|
||||
|
||||
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
||||
every referencing equipment folder, and up to the Server object. A subscriber at any one
|
||||
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
||||
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
||||
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
||||
Server-object dedup + Part 9 ack correlation).
|
||||
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
||||
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
|
||||
inverse-notifier entries never leak across redeploys.
|
||||
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
|
||||
which notifier root the operator subscribed at — an ack issued from an equipment-folder
|
||||
subscription resolves to the same raw condition.
|
||||
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
|
||||
## Condition event identity fields (what a client reads on the wire)
|
||||
|
||||
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
||||
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
||||
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
||||
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
||||
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
|
||||
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
|
||||
|
||||
| Field | Value | Notes |
|
||||
|---|---|---|
|
||||
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
||||
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||
| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below |
|
||||
|
||||
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
||||
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
|
||||
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
||||
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
||||
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
||||
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
||||
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
||||
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
||||
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
||||
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
||||
|
||||
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
||||
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
||||
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
||||
|
||||
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
||||
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
||||
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
||||
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
||||
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
||||
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
||||
|
||||
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
|
||||
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
|
||||
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
|
||||
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
|
||||
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
|
||||
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
|
||||
|
||||
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
||||
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
||||
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
||||
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
||||
> alarm-history writer stamps that field with the **EquipmentPath**
|
||||
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
||||
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
||||
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||
> live-path fix above does not change the history path.)
|
||||
|
||||
### Condition source-data Quality (#477)
|
||||
|
||||
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
|
||||
because `StatusCodes.Good == 0x00000000` an unassigned `StatusCode` **is** `Good` — so every condition
|
||||
reported `Good` unconditionally (a wrong *value*, not a null like #473/#475). A native alarm whose device
|
||||
went offline still read `Good`, so an operator could not tell *"genuinely inactive"* from *"we have lost
|
||||
contact and do not know"*.
|
||||
|
||||
**How it is driven now (native alarms).** An alarm-bearing raw tag materializes a condition with **no
|
||||
sibling value variable**, so the value/quality path (`WriteValue`) never touches it, and a comms-lost
|
||||
driver emits **no alarm transitions** (the feed goes silent). The quality therefore comes from the
|
||||
**driver's connectivity**, out of band from alarm transitions:
|
||||
|
||||
- `DriverInstanceActor` Tells its host `ConnectivityChanged(driverInstanceId, connected)` on every
|
||||
transition into `Connected` (`true`) / `Reconnecting` (`false`).
|
||||
- `DriverHostActor.OnDriverConnectivityChanged` fans that out to **every** native condition the driver
|
||||
owns as an `OpcUaPublishActor.AlarmQualityUpdate` (`Good` on connect, `Bad` on disconnect).
|
||||
- `OtOpcUaNodeManager.WriteAlarmQuality` sets **only** the condition's `Quality` and fires a Part 9
|
||||
event **only on a quality-bucket change** — it never touches Active/Acked/Severity/Retain (an active
|
||||
alarm that loses comms stays active). This is a dedicated path, *not* a full-snapshot re-projection, so
|
||||
it cannot clobber a condition's severity/message and works for a condition that never fired a transition.
|
||||
- A freshly materialized native condition starts `BadWaitingForInitialData` (the "no driver data yet"
|
||||
convention value variables use); the first `Connected` confirms it `Good`.
|
||||
- The connectivity annotation is **ungated by redundancy role** (a Secondary keeps its condition quality
|
||||
warm for failover) and publishes **no `/alerts` row** — driver comms health already has its own status
|
||||
surface (`IDriverHealthPublisher`); a row per condition would be alarm-fatigue.
|
||||
|
||||
**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags,
|
||||
so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this
|
||||
condition's state?") — mirroring the native OT semantic:
|
||||
|
||||
- The mux now forwards each input's source quality (`DependencyValueChanged.Quality`), and the scripted host
|
||||
pushes it into the engine's read cache (previously every mux value was treated as `Good`).
|
||||
- The `ScriptedAlarmEngine` computes the worst input quality each evaluation. A **real transition** carries
|
||||
it on the emitted event → `ScriptedAlarmHostActor.ToSnapshot` projects it (so a transition fired while an
|
||||
input is `Uncertain` does not clobber quality back to `Good`).
|
||||
- A **Bad input freezes the condition** (`AreInputsReady` holds its state — no transition), exactly like a
|
||||
comms-lost native driver. So a quality-bucket change with no transition is emitted as
|
||||
`EmissionKind.QualityChanged` and routed to the **same** dedicated `AlarmQualityUpdate → WriteAlarmQuality`
|
||||
node path native uses (quality only, one Part 9 event on a bucket change, **no `/alerts` row**, no
|
||||
historian write). `ScriptedAlarmSource` skips `QualityChanged` so it never fabricates a phantom
|
||||
`IAlarmSource` event.
|
||||
- An input that has **not been published yet** (cold start) is *not* a quality signal (that is the readiness
|
||||
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
|
||||
actually-`Bad` published value flips the bucket and annotates.
|
||||
|
||||
**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
|
||||
data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
|
||||
per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
|
||||
goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
|
||||
`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||
condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
|
||||
`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
|
||||
`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
|
||||
as the Layer-4 follow-up (#481).
|
||||
|
||||
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
|
||||
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
|
||||
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
|
||||
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
|
||||
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
|
||||
`Transition_snapshot_carries_worst_input_quality`).
|
||||
|
||||
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests.Condition_event_Quality_tracks_source_connectivity_on_the_wire`
|
||||
subscribes with a `[Quality, Message]` clause (Quality is declared on `ConditionType`) and asserts a
|
||||
healthy source reports `Good`, a comms-lost source reports non-`Good`, and recovery returns to `Good` — on
|
||||
a real client subscription. `NodeManagerAlarmSourceFieldsTests` guards the node itself + the no-clobber /
|
||||
unknown-node-no-op invariants.
|
||||
|
||||
## Galaxy driver path (driver-native)
|
||||
|
||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||
|
||||
@@ -146,6 +146,28 @@ key must carry the scopes `historian:read`, `historian:write`, `historian:tags:w
|
||||
[`docs/Historian.md`](Historian.md) for the full key reference, the migration note (old Wonderware keys →
|
||||
gateway keys), and the deployment prerequisites.
|
||||
|
||||
### `LocalDb` (pair-local config cache — driver role only)
|
||||
|
||||
Driver-role nodes keep a consolidated **`ZB.MOM.WW.LocalDb`** SQLite database that caches the deployed
|
||||
configuration artifact (chunked) so the node can **boot from cache when central SQL is unreachable**. The
|
||||
section is bound by `AddOtOpcUaLocalDb` (`Host/Configuration/LocalDbRegistration.cs`) inside the `hasDriver`
|
||||
branch — **admin-only nodes never register it, and never require `LocalDb:Path`**. Storage is unconditional;
|
||||
replication stays inert until the sync port + peer are set.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `LocalDb:Path` | `./data/otopcua-localdb.db` | SQLite file path. `ValidateOnStart`-required once `AddZbLocalDb` runs (driver nodes only). In containers put it on a durable volume. |
|
||||
| `LocalDb:SyncListenPort` | `0` | `0` = replication off (no sync listener). A non-zero port binds a dedicated **h2c** sync listener; set the **same** port on both pair nodes. Setting it makes the Host add an explicit Kestrel `Listen*` and re-bind the primary HTTP port (an explicit `Listen*` otherwise discards `ASPNETCORE_URLS`). |
|
||||
| `LocalDb:Replication:PeerAddress` | `""` | The peer this node **dials** (`http://<peer>:<port>`). Set on the initiator only; leave empty on the passive node. The stream is bidirectional. |
|
||||
| `LocalDb:Replication:ApiKey` | `""` | Bearer token for the sync stream. **Fail-closed:** no key ⇒ the passive endpoint refuses everything; a mismatch ⇒ the pair silently stops converging. **Must be byte-identical on both nodes.** Supply via `${secret:...}` / env — never a cleartext literal in production. |
|
||||
| `LocalDb:Replication:MaxBatchSize` | (library default) | Rows per replication batch — **row-count-only** against gRPC's 4 MB cap. `16` for the artifact cache (chunk rows ≈171 KB, so 16 × 171 KB ≈ 2.7 MB). |
|
||||
|
||||
Replication is **default-OFF** across the fleet; it is enabled per-pair as an opt-in. See
|
||||
[`docs/operations/2026-07-20-localdb-pair-replication.md`](operations/2026-07-20-localdb-pair-replication.md)
|
||||
for the enablement runbook (ApiKey handling, stop/start-together rule, tombstone-retention window, and the
|
||||
never-`sqlite3`-a-live-WAL-DB inspection rules) and the "pair-local config cache" section of
|
||||
[`docs/Redundancy.md`](Redundancy.md) for what boot-from-cache does and does not cover.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
+23
-6
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
|
||||
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
||||
OPC UA client can discover historized capability from the node's attributes.
|
||||
|
||||
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
|
||||
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
|
||||
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
|
||||
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
|
||||
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
|
||||
> returns the same series. The historization intent is single-sourced — the mux's
|
||||
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
|
||||
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
|
||||
|
||||
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
||||
owns at least one alarm condition is already an event notifier; the server registers a
|
||||
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
||||
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
|
||||
The `historyread` command reads historical data from any node. Supply start and end times in
|
||||
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
||||
|
||||
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
|
||||
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
|
||||
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
|
||||
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
|
||||
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
|
||||
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
|
||||
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
|
||||
|
||||
```bash
|
||||
# Raw history for a historized Galaxy tag (last 24 hours by default)
|
||||
# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
||||
|
||||
# Limit to 100 values
|
||||
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=3;s=filling/line1/station1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
--max 100
|
||||
|
||||
# 1-hour average aggregate
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
--aggregate Average --interval 3600000
|
||||
|
||||
# Authenticated read (ReadOnly role or higher required)
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
-U reader -P password
|
||||
```
|
||||
|
||||
@@ -226,6 +226,35 @@ Net effect: each alarm transition appears **once** on `/alerts` and would histor
|
||||
|
||||
See [ScriptedAlarms.md](ScriptedAlarms.md) and [AlarmTracking.md](AlarmTracking.md) for the scripted-alarm engine internals.
|
||||
|
||||
## Pair-local config cache (LocalDb — Phase 1)
|
||||
|
||||
Independently of the ServiceLevel machinery above, every **driver-role** node keeps a consolidated
|
||||
[`ZB.MOM.WW.LocalDb`](../CLAUDE.md) SQLite database that caches the **deployed-configuration
|
||||
artifact** (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver
|
||||
node restarting into a **central-SQL-Server outage** would come up with no configuration at all. With
|
||||
the cache, it **boots from the last artifact it applied** instead, and logs a running-from-cache
|
||||
signal.
|
||||
|
||||
- **What replicates.** The cache can *optionally* replicate to the node's redundant pair peer over
|
||||
the LocalDb library's gRPC sync (HLC-stamped, last-writer-wins). Two tables replicate:
|
||||
`deployment_artifacts` and `deployment_pointer`. Replication is **default-OFF and fail-closed** —
|
||||
it stays inert until `LocalDb:SyncListenPort` + a peer are configured, and a missing/mismatched
|
||||
`LocalDb:Replication:ApiKey` refuses or silently halts sync. It is enabled per-pair as an opt-in.
|
||||
- **The replicated-cache payoff.** In a pair with replication on, *either* node can be the one that
|
||||
applied the latest deploy; the peer holds a byte-identical copy. So a node that restarts during a
|
||||
central outage can boot the current config **even if it never applied that deploy itself** — its
|
||||
peer did, and the row replicated.
|
||||
- **What it does NOT cover.** The cache is a **boot fallback for central-SQL outages only**, not a
|
||||
standby data path and not a replacement for central. A **new deployment still requires central
|
||||
SQL**; the cache is read only when the central fetch fails at startup, and the node resumes
|
||||
fresh-config behavior once central returns. It does not replicate live tag values, alarms, or
|
||||
historian data — only the config artifact. It is orthogonal to the ServiceLevel/primary-gate
|
||||
logic: a node running from cache still participates in redundancy normally.
|
||||
|
||||
Full detail: the enablement runbook at
|
||||
[`docs/operations/2026-07-20-localdb-pair-replication.md`](operations/2026-07-20-localdb-pair-replication.md)
|
||||
and the `LocalDb` section of [`docs/Configuration.md`](Configuration.md).
|
||||
|
||||
## Client-side failover
|
||||
|
||||
The OtOpcUa Client CLI at `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md).
|
||||
|
||||
@@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own
|
||||
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
|
||||
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
||||
|
||||
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
|
||||
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
|
||||
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
|
||||
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
|
||||
> scripts key off the single value source regardless of the UNS projection. The dual
|
||||
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
|
||||
|
||||
### Literal gate
|
||||
|
||||
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
||||
|
||||
+21
-4
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
|
||||
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
||||
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
||||
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
||||
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy
|
||||
blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
|
||||
(`DeploymentArtifact`) paths.
|
||||
|
||||
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
|
||||
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
|
||||
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
|
||||
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
|
||||
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
|
||||
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
|
||||
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
|
||||
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
|
||||
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
|
||||
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
|
||||
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
|
||||
> `/alerts` row lists the referencing equipment paths. See
|
||||
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
|
||||
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||
|
||||
### TagConfig alarm fields
|
||||
|
||||
| Field | Values | Default |
|
||||
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
|
||||
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
||||
for failover.
|
||||
|
||||
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
|
||||
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
|
||||
other configuration is required.
|
||||
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
|
||||
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
|
||||
tags). No other configuration is required: any equipment that references the raw tag
|
||||
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
|
||||
|
||||
### Native-alarm OPC UA operator operations
|
||||
|
||||
|
||||
+35
@@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
|
||||
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
||||
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
|
||||
|
||||
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
|
||||
|
||||
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
|
||||
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
|
||||
>
|
||||
> | Namespace URI | Subtree | NodeId `s=` scheme |
|
||||
> |---|---|---|
|
||||
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||
|
||||
Every device value has **exactly one source** — the raw tag's node in the Raw
|
||||
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
|
||||
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
|
||||
(the display-name override else the raw tag's `Name`). The UNS variable does not
|
||||
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
|
||||
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
|
||||
raw NodeId AND every referencing UNS NodeId with identical value / quality /
|
||||
source-timestamp — the two NodeIds never drift.
|
||||
|
||||
- **Reads / subscriptions** work through either NodeId and return the same data.
|
||||
- **Writes** route through either NodeId to the same backing driver ref under the
|
||||
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
|
||||
than the raw write it fans from); a failed device write reverts both NodeIds via
|
||||
the shared fan-out.
|
||||
- **HistoryRead** works through either NodeId and returns the same series — both
|
||||
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
|
||||
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
|
||||
|
||||
Note the two distinct identity strings: the wire **NodeId** is the path
|
||||
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
|
||||
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
|
||||
space the guards enforce). They are related but not the same string.
|
||||
|
||||
### Virtual tags
|
||||
|
||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Secrets: Clustered Master-Key Posture (All Roles)
|
||||
|
||||
## Purpose
|
||||
|
||||
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
||||
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
|
||||
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
|
||||
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
|
||||
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
|
||||
registered unconditionally, independent of node role.
|
||||
|
||||
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
|
||||
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
|
||||
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
|
||||
fused admin+driver node, or a driver-only node). This runbook covers what a
|
||||
production deployment needs so that secret resolution behaves identically on
|
||||
**every node regardless of role** — not just admin nodes. That matters here more
|
||||
than it might elsewhere: the pre-host expander and the runtime resolver both run
|
||||
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
|
||||
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
|
||||
runtime, not just at boot. It does **not** change any code; it is an
|
||||
operations/deployment posture, delivered out-of-band from the committed config.
|
||||
|
||||
## The two hard requirements
|
||||
|
||||
For the pre-host expander (and the runtime resolver) to resolve the same
|
||||
plaintext secret on every node, no matter its role:
|
||||
|
||||
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
|
||||
combination — must unwrap the store with the exact same master key. A
|
||||
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
||||
decrypt every *other* node's ciphertext rows to garbage.
|
||||
2. **Identical store rows on every node.** All nodes must read the same SQLite
|
||||
database (same file, or a replicated/shared copy with the same rows) — not
|
||||
independently-seeded stores that happen to share a KEK.
|
||||
|
||||
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
|
||||
— there is no built-in cross-node replication today. Meeting both requirements in
|
||||
production is a deployment concern, covered below.
|
||||
|
||||
## Recommended interim posture (G-5)
|
||||
|
||||
Until real replication exists (G-7, below), the recommended production posture is:
|
||||
|
||||
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
|
||||
key file that is identical on every node of every role** — a base64-encoded
|
||||
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
|
||||
to each node's filesystem/secret-mount by the deployment tooling, and **never
|
||||
committed** to the repo. Treat it with the same discipline as any other
|
||||
production secret (restrictive file ACLs, no logging, rotated via a future
|
||||
KEK-rotation runbook — not yet built).
|
||||
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
|
||||
that every node mounts (admin, driver, and dev/fused alike), so every node's
|
||||
migrator opens and reads the same rows at boot.
|
||||
|
||||
Writes to the store are rare and human-driven — an operator using the
|
||||
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
|
||||
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
|
||||
regardless of role. The access pattern is read-mostly / effectively
|
||||
single-writer, which is what makes a shared SQLite volume viable as an interim
|
||||
posture (see caveat below).
|
||||
|
||||
## How it's delivered (do NOT commit these values)
|
||||
|
||||
The File-KEK + shared-store posture is supplied per-node at deployment time —
|
||||
**never** by editing the committed `appsettings.json` or the role-overlay files
|
||||
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
|
||||
Those committed overlays are also consumed by local dev and the
|
||||
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
|
||||
into them would break every dev/test/CI boot. Two acceptable delivery
|
||||
mechanisms instead:
|
||||
|
||||
**Option A — environment variable overrides** (Windows Service / NSSM env block,
|
||||
container `env_file`, etc.), applied identically on every node regardless of role:
|
||||
|
||||
```
|
||||
# production deployment — do not commit to the dev appsettings
|
||||
Secrets__MasterKey__Source=File
|
||||
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
|
||||
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
|
||||
```
|
||||
|
||||
**Option B — a production-only config layer** that is *not* the committed dev
|
||||
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
|
||||
binaries, or an orchestrator-injected config mount):
|
||||
|
||||
```jsonc
|
||||
// production deployment — do not commit to the dev appsettings
|
||||
{
|
||||
"Secrets": {
|
||||
"MasterKey": {
|
||||
"Source": "File",
|
||||
"FilePath": "/run/secrets/otopcua-master.key"
|
||||
},
|
||||
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Either way, the file/path referenced must exist and be identical on every node
|
||||
**before** that node boots — the pre-host expander runs unconditionally on every
|
||||
role and will throw (`SecretNotFoundException` / migration failure) if the store
|
||||
or key is missing.
|
||||
|
||||
## Caveat: SQLite over a shared volume is not real replication
|
||||
|
||||
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
||||
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
||||
block volume only one writer should be active at a time). The interim posture
|
||||
above is acceptable because:
|
||||
|
||||
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
||||
- Writes are rare, human-initiated, and effectively single-writer in practice
|
||||
(an operator runs the CLI/UI against one admin node at a time).
|
||||
|
||||
It is **not** a substitute for real replication, and it is not safe if multiple
|
||||
nodes attempt concurrent writes. Do not build automation that writes secrets
|
||||
from more than one node simultaneously.
|
||||
|
||||
## Data Protection is independent — do not touch it here
|
||||
|
||||
OtOpcUa's cookie/session protection already has its own clustered-key story:
|
||||
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
|
||||
which shares the Data Protection key ring across every node via the existing
|
||||
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
|
||||
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
||||
work — doing so risks invalidating active sessions for an unrelated reason.
|
||||
|
||||
It is, however, the model for where the *next* iteration of secret storage
|
||||
should go — see the G-7 hand-off below.
|
||||
|
||||
## The G-7 hand-off
|
||||
|
||||
The posture above is an interim, ops-only workaround. The long-term shape,
|
||||
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
|
||||
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
|
||||
the Data Protection key ring:
|
||||
|
||||
```csharp
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
.SetApplicationName("OtOpcUa");
|
||||
```
|
||||
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
|
||||
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
|
||||
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
|
||||
the secret store is the natural next tenant of that same shared database instead
|
||||
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
|
||||
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
|
||||
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
|
||||
built as part of this cut. This runbook's shared-SQLite-volume posture is the
|
||||
bridge until G-7 lands.
|
||||
|
||||
## Dev/test/default posture (unchanged)
|
||||
|
||||
The committed default in `appsettings.json` is:
|
||||
|
||||
```json
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||
"RunMigrationsOnStartup": true
|
||||
}
|
||||
```
|
||||
|
||||
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
|
||||
path is relative to the working directory, so local dev, the role-overlay
|
||||
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
|
||||
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
|
||||
tests all boot cleanly with no external mount. The File-KEK + shared-volume
|
||||
posture in this runbook applies only to real clustered production deployments —
|
||||
it must never be baked into the committed dev/role-overlay base, because the
|
||||
expander runs unconditionally at every node boot (any role) and would break
|
||||
dev/CI if pointed at a nonexistent `/shared` mount.
|
||||
@@ -0,0 +1,136 @@
|
||||
# LocalDb pair replication — operations runbook
|
||||
|
||||
> **Phase 1 scope.** Every driver-role OtOpcUa node keeps a consolidated
|
||||
> [`ZB.MOM.WW.LocalDb`](../../CLAUDE.md) SQLite database. Today it caches exactly one thing: the
|
||||
> **deployed-configuration artifact**, chunked, so a node can **boot from cache when central SQL
|
||||
> Server is unreachable**. That cache can *optionally* replicate to the node's redundant pair peer
|
||||
> over the library's gRPC sync. **Replication is default-OFF and fail-closed.** This runbook covers
|
||||
> enabling it, the operational rules that keep a pair converging, and the DB-inspection safety
|
||||
> rules.
|
||||
|
||||
## What replicates, and what it buys you
|
||||
|
||||
- **Replicated tables:** `deployment_artifacts` (the artifact, split into base64 chunks) and
|
||||
`deployment_pointer` (one current-deployment pointer per cluster). Registered — in this order,
|
||||
after the DDL — by `LocalDbSetup.OnReady`.
|
||||
- **The payoff:** in a redundant driver pair, *either* node can be the one that applied the latest
|
||||
deploy and wrote the cache. With replication on, the peer holds a byte-identical copy. So a node
|
||||
that restarts into a central-SQL outage can boot the current config **even if it never applied
|
||||
that deploy itself** — its peer did, and the row replicated.
|
||||
- **Convergence model:** last-writer-wins per primary key, HLC-stamped. Both nodes also store
|
||||
locally after every apply, so the pair converges to identical content regardless of which node
|
||||
deployed. Retention (newest-2 deployments per cluster) prunes on one node and the deletes
|
||||
replicate as tombstones.
|
||||
|
||||
## Enabling replication on a pair
|
||||
|
||||
Replication has two knobs, both under `LocalDb`:
|
||||
|
||||
| Key | Role | Notes |
|
||||
|---|---|---|
|
||||
| `LocalDb:SyncListenPort` | binds the dedicated **h2c** sync listener | `0` (default) = no listener, replication off. Set the **same** non-zero port on **both** nodes of the pair. |
|
||||
| `LocalDb:Replication:PeerAddress` | the address this node **dials** | Set on **one** node (the initiator); leave empty on the other (passive). The stream is bidirectional — one dial carries both directions. |
|
||||
| `LocalDb:Replication:ApiKey` | bearer token for the sync stream | **Must be byte-identical on both nodes.** See fail-closed rule below. Supply via `${secret:...}` / env — never a cleartext literal in production config. |
|
||||
| `LocalDb:Replication:MaxBatchSize` | rows per replication batch | `16` for the artifact cache. Batching is **row-count-only** against gRPC's 4 MB message cap; artifact chunk rows are ≈171 KB, so 16 × 171 KB ≈ 2.7 MB stays under the cap. Raising it risks tripping the cap. |
|
||||
|
||||
Example (site-a-1 initiator, site-a-2 passive):
|
||||
|
||||
```
|
||||
site-a-1: LocalDb__SyncListenPort=9001
|
||||
LocalDb__Replication__PeerAddress=http://site-a-2:9001
|
||||
LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key}
|
||||
LocalDb__Replication__MaxBatchSize=16
|
||||
site-a-2: LocalDb__SyncListenPort=9001
|
||||
LocalDb__Replication__ApiKey=${secret:site-a-localdb-sync-key} # no PeerAddress
|
||||
LocalDb__Replication__MaxBatchSize=16
|
||||
```
|
||||
|
||||
> **Kestrel note.** Setting `SyncListenPort` makes the Host add an explicit `Listen*` for the h2c
|
||||
> sync listener. An explicit `Listen*` makes Kestrel **ignore `ASPNETCORE_URLS` entirely**, so the
|
||||
> Host also re-binds the primary HTTP port in the same block. If you change the primary port,
|
||||
> verify both `:<httpPort>` and `:<syncPort>` appear in the startup "Now listening on" lines.
|
||||
|
||||
### The ApiKey rule (fail-closed)
|
||||
|
||||
The replication library's passive endpoint verifies **no** authentication — the host
|
||||
`LocalDbSyncAuthInterceptor` is the only gate, and it is **fail-closed**:
|
||||
|
||||
- **No key configured ⇒ every sync call is refused** (`PermissionDenied`). "No key" is never
|
||||
"no auth required".
|
||||
- **A key mismatch ⇒ the pair silently stops converging.** The initiator's stream is rejected at
|
||||
the peer; nothing errors loudly. A typo in one node's key looks exactly like "replication is
|
||||
broken". If a pair is not converging, **check the keys match first.**
|
||||
|
||||
## Operational rules
|
||||
|
||||
- **Stop / start the pair together where you can.** Each node keeps working (and caching locally)
|
||||
while its peer is down; the outage is not a data-loss event — the surviving node accumulates
|
||||
writes and the peer catches up on rejoin. But a long-lived solo node drifts further from its
|
||||
peer, so avoid leaving a pair split for extended periods.
|
||||
- **Tombstone-retention resurrection window.** Retention prunes to the newest 2 deployments and
|
||||
replicates the prune as tombstones. Tombstones are themselves retained only for a bounded window.
|
||||
If a node is offline **longer than the tombstone-retention window**, a delete that happened during
|
||||
its outage may no longer be expressible as a tombstone on rejoin — a pruned deployment could
|
||||
briefly reappear until the next deploy re-prunes it. Keep pair outages well inside that window.
|
||||
- **A rebuilt node is back-filled by its peer.** If a node loses its LocalDb file — a wiped volume,
|
||||
a re-imaged host, a fresh container — it rejoins with an empty database and its peer snapshots the
|
||||
cached configuration back to it, with no new deploy and no central SQL. Two things are worth
|
||||
knowing: this needs **`ZB.MOM.WW.LocalDb` ≥ 0.1.3**, and it heals the *cache*, so the node regains
|
||||
boot-from-cache for future outages. (On `0.1.1` a converged pair has pruned every oplog row on ack
|
||||
and the wiped node was never snapshotted — it stayed empty until the next deploy. On `0.1.2` it is
|
||||
back-filled but its OWN writes are silently dropped until its restarted seq counter climbs past the
|
||||
peer's stale watermark, so the pair looks converged right up until the moment only the rebuilt node
|
||||
applies a deploy.) A node
|
||||
wiped **during** a central outage is still repopulated by its peer under 0.1.2; a node with **no**
|
||||
peer replication configured self-heals only from central on its next successful apply.
|
||||
|
||||
- **What boot-from-cache does NOT cover.** The cache is a *fallback for central-SQL outages at
|
||||
boot*, not a replacement for central. A **new deployment still requires central SQL** — the cache
|
||||
is only read when the central fetch fails at startup. When central returns, the node resumes
|
||||
fresh-config behavior. Boot-from-cache logs a running-from-cache signal; treat a node that stays
|
||||
on it as a central-connectivity incident, not steady state.
|
||||
|
||||
## Inspecting the LocalDb file — safety rules
|
||||
|
||||
The database runs in **WAL mode**. These rules exist because violating them corrupted a live DB in
|
||||
the 2026-07-20 ScadaBridge incident:
|
||||
|
||||
- **Never run `sqlite3` on the live file** (host-side, against a bind-mounted or container path).
|
||||
Opening a live WAL DB from a second process across virtiofs poisons the WAL. **Always copy the
|
||||
triplet first** and query the copy:
|
||||
|
||||
```bash
|
||||
docker cp <node>:/app/data/otopcua-localdb.db /tmp/localdb.db
|
||||
docker cp <node>:/app/data/otopcua-localdb.db-wal /tmp/localdb.db-wal
|
||||
docker cp <node>:/app/data/otopcua-localdb.db-shm /tmp/localdb.db-shm
|
||||
sqlite3 /tmp/localdb.db 'SELECT cluster_id, deployment_id FROM deployment_pointer;'
|
||||
```
|
||||
|
||||
- **Metrics** come from the container, not a host curl (`aspnet:10.0` has no `curl`):
|
||||
|
||||
```bash
|
||||
docker run --rm --network container:<node> curlimages/curl:latest -s localhost:<httpPort>/metrics | grep localdb_
|
||||
```
|
||||
|
||||
- **If an anomaly appears within seconds of your own measurement, suspect the measurement.**
|
||||
Restart both nodes and re-observe untouched before blaming the library.
|
||||
|
||||
## Useful queries (on a copied triplet)
|
||||
|
||||
```sql
|
||||
-- What each node thinks the current deployment is
|
||||
SELECT cluster_id, deployment_id, revision_hash, applied_at_utc FROM deployment_pointer;
|
||||
|
||||
-- Convergence check: the pointer's origin stamp should match on both nodes
|
||||
SELECT pk_json, hlc, node_id, is_tombstone
|
||||
FROM __localdb_row_version WHERE table_name = 'deployment_pointer' ORDER BY pk_json;
|
||||
|
||||
-- Replication backlog (should drain to 0 when a pair is caught up)
|
||||
SELECT COUNT(*) FROM __localdb_oplog;
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/Redundancy.md`](../Redundancy.md) — the pair-local config cache section.
|
||||
- [`docs/Configuration.md`](../Configuration.md) — the `LocalDb` appsettings section.
|
||||
- The two-node convergence harness: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
|
||||
|
||||
Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1–WP5), executed via the
|
||||
`/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
|
||||
code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate**
|
||||
on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0.
|
||||
|
||||
## What landed
|
||||
|
||||
- **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single
|
||||
`…/ns`), `V3NodeIds` (raw = `s=<RawPath>`, UNS = `s=<Area>/<Line>/<Equipment>/<EffectiveName>`), and the
|
||||
`AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam.
|
||||
- **Wave A** (2 agents ∥):
|
||||
- **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag,
|
||||
tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable
|
||||
`(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes`
|
||||
UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment
|
||||
paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped
|
||||
`EquipmentNamespaceMaterializationTests`.
|
||||
- **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method
|
||||
(maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized
|
||||
UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through
|
||||
`DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new
|
||||
`AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint
|
||||
no-op, forwarded + reflection-covered).
|
||||
- **Wave B** (1 agent — the delicate one):
|
||||
- **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId
|
||||
registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS
|
||||
NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds;
|
||||
`EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**:
|
||||
**H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS
|
||||
path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node
|
||||
self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity
|
||||
round-trip test.
|
||||
- **Wave C** (2 agents ∥):
|
||||
- **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw
|
||||
device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy —
|
||||
proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on
|
||||
rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
|
||||
`AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery
|
||||
test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
|
||||
- **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node
|
||||
harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`,
|
||||
`docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`).
|
||||
- **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s
|
||||
`AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit).
|
||||
- **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved
|
||||
live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan
|
||||
`VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad
|
||||
forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node
|
||||
reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips
|
||||
historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).)
|
||||
|
||||
## Reviewer verdict per wave
|
||||
|
||||
No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded
|
||||
methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed.
|
||||
Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent
|
||||
+ teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real,
|
||||
dedup/02-S13 semantics intact; M1/M2/LOW-3 closed.
|
||||
|
||||
## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code)
|
||||
|
||||
1. **Browse both namespaces** — `ns=2` Raw tree (`pymodbus/plc/HR200`, `s=<RawPath>`) + `ns=3` UNS tree
|
||||
(`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`.
|
||||
2. **Single-source fan-out** — `MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp;
|
||||
Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix).
|
||||
3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the
|
||||
raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write
|
||||
dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig).
|
||||
4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same
|
||||
historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`.
|
||||
5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag
|
||||
(ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving
|
||||
it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery
|
||||
(exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire
|
||||
`NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one)
|
||||
so a live transition can't be tripped on the rig.
|
||||
6. **Rename cascade** — renamed `HR200`→`HR200X` + deployed: old raw NodeId gone, new present; the UNS
|
||||
reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the
|
||||
renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad
|
||||
0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live
|
||||
driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.)
|
||||
7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary
|
||||
differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the
|
||||
alarm-emit gate is Primary-only).
|
||||
|
||||
## Tests
|
||||
|
||||
Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests
|
||||
dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green
|
||||
(the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy
|
||||
`EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).
|
||||
|
||||
## Documented follow-ups (non-blocking)
|
||||
|
||||
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
|
||||
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
|
||||
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
|
||||
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
|
||||
`EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted
|
||||
alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real
|
||||
transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression).
|
||||
Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk**
|
||||
(a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1
|
||||
historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded`
|
||||
(same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`,
|
||||
**never touching the `alerts` topic**.
|
||||
- Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate). Filed as issue #489.
|
||||
- Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works). Filed as issue #490.
|
||||
- Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=<RawPath>` /
|
||||
UNS `s=<Area>/<Line>/<Equipment>/<EffectiveName>`, retired `EquipmentNodeIds`) — tracked in the
|
||||
ScadaBridge repo as its issue #14 (its #20 landed the `nsu=` binding + native-alarm routing). The umbrella
|
||||
index `../scadaproj/CLAUDE.md` OtOpcUa entry is **DONE** (scadaproj `ede5275`).
|
||||
|
||||
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
|
||||
@@ -0,0 +1,187 @@
|
||||
# Alarm condition Quality (issue #477) — design
|
||||
|
||||
**Status:** implemented (L1+L2) · **Date:** 2026-07-17 · **Issue:** #477 (follow-up chain #473 → #475 → #477)
|
||||
**Scope decision:** Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → **#478**.
|
||||
|
||||
## Problem
|
||||
|
||||
`AlarmConditionState.Quality` is never assigned anywhere in `src/` — neither by
|
||||
`OtOpcUaNodeManager.MaterialiseAlarmCondition` nor by the `WriteAlarmCondition` transition path.
|
||||
Because `StatusCodes.Good == 0x00000000`, `default(StatusCode)` **is** `Good`, so the field is
|
||||
*accidentally valid* — clients parse it, but it reports **`Good` unconditionally regardless of the
|
||||
backing tag's real quality**.
|
||||
|
||||
This is a wrong-*value* bug, not the null-value bug class of #473/#475. Part 9 defines
|
||||
`ConditionType.Quality` as "the quality of the Condition's source data". OT impact: when a native
|
||||
alarm's device goes offline (comms lost) the condition still reports `Quality = Good`, so an operator
|
||||
(or an HMI bucketing on `IsGood`) cannot distinguish *"genuinely not active"* from *"we have lost
|
||||
contact and do not know"*.
|
||||
|
||||
## Why it isn't a 2-line default (confirmed by code)
|
||||
|
||||
1. **Alarm-bearing raw tags have no value variable.** `AddressSpaceApplier` materialises a raw tag as
|
||||
*either* a condition node (`tag.Alarm is not null`) *or* a value variable (`else`) — never both,
|
||||
since they'd share the same `s=<RawPath>` NodeId. So `WriteValue` (the only path carrying
|
||||
`OpcUaQuality`) is never invoked for an alarm node. Quality has nowhere to land today.
|
||||
2. **The alarm channel is quality-blind.** `AlarmEventArgs` (driver → host) and `AlarmConditionSnapshot`
|
||||
(host → SDK sink) both carry no quality field.
|
||||
3. **On comms-loss the alarm feed goes silent.** `DriverInstanceActor` on `DisconnectObserved` detaches
|
||||
the alarm subscription and re-enters `Reconnecting` — no transition event ever arrives to carry Bad.
|
||||
So the "device offline" signal must come from **driver connectivity**, independently of alarm
|
||||
transitions.
|
||||
|
||||
## Decisions (the issue's open questions)
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
|
||||
| 2 | Direct status code vs. policy map | **Direct Bad** on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for `IsGood` bucketing. |
|
||||
| 3 | Does Bad also suppress transitions / touch Retain? | **No — annotate only** | A comms-lost *active* condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
|
||||
| 4 | Scripted alarms: worst-of-inputs quality? | **Deferred (Layer 3)** | Scripted conditions stay `Good`. Filed as a follow-up issue. |
|
||||
|
||||
## Architecture — reuse the existing publish path, add no sink method
|
||||
|
||||
The key move: **do not add a new `IOpcUaAddressSpaceSink` method.** A new sink-interface surface would
|
||||
have to be forwarded through `DeferredAddressSpaceSink` or it is inert on driver hosts (the F10b
|
||||
prod-inertness trap). Instead the `NativeAlarmProjector` becomes the single owner of per-condition
|
||||
state *and* quality, and a connectivity change re-projects the *last* snapshot with a swapped quality
|
||||
through the **existing** `AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition` path.
|
||||
|
||||
### Layer 1 — make Quality a real, plumbed field
|
||||
|
||||
- `AlarmConditionSnapshot` (Commons) gains `OpcUaQuality Quality` (last positional param, default
|
||||
`OpcUaQuality.Good` so scripted callers and existing tests keep compiling; Commons already knows
|
||||
`OpcUaQuality` via `IOpcUaAddressSpaceSink`).
|
||||
- `MaterialiseAlarmCondition` sets `alarm.Quality.Value` at build time:
|
||||
**native → `BadWaitingForInitialData`** (honest until connectivity confirms Good, matching the
|
||||
value-variable "waiting for initial data" convention), **scripted → `Good`** (script-computed, always
|
||||
live in this scope).
|
||||
- `WriteAlarmCondition` projects `StatusFromQuality(state.Quality)` onto `condition.Quality.Value`
|
||||
(+ `SourceTimestamp`).
|
||||
- The delta-gate (`AlarmConditionDelta` / `ReadConditionDelta` / `ToConditionDelta`) gains a `Quality`
|
||||
member, so a Good→Bad bucket change is a genuine delta and **fires a Part 9 condition event**.
|
||||
|
||||
### Layer 2 — drive native quality from driver connectivity
|
||||
|
||||
- `DriverInstanceActor`: new `public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected)`.
|
||||
`Context.Parent.Tell` it on `Become(Connected)` entry (`true`) and on the transitions into
|
||||
`Reconnecting` (`DisconnectObserved` / `ForceReconnect`) (`false`). Fire-and-forget, mirrors
|
||||
`DeltaApplied`.
|
||||
- `NativeAlarmProjector`: per-node state becomes `(bool Active, bool Acked, OpcUaQuality Quality)`.
|
||||
`Project(transition)` preserves the current quality; new `ProjectQuality(nodeId, quality)` preserves
|
||||
Active/Acked and swaps only the quality, returning a full snapshot.
|
||||
- `DriverHostActor`: `Receive<ConnectivityChanged>` iterates `_alarmNodeIdByDriverRef` for that driver
|
||||
instance and Tells one `AlarmStateUpdate` per condition with the re-projected snapshot
|
||||
(`connected ? Good : Bad`). **Ungated** — both redundancy nodes track their own driver's comms, matching
|
||||
the existing "condition write stays ungated (Secondary keeps its address space warm)" rule.
|
||||
**No `/alerts` row** for a quality-only change — driver health already has its own status/alerts surface
|
||||
via `IDriverHealthPublisher`; a row here would be alarm-fatigue.
|
||||
|
||||
Scripted alarms are unaffected: they are not driver instances, receive no `ConnectivityChanged`, and
|
||||
their snapshot quality stays `Good`.
|
||||
|
||||
## Files
|
||||
|
||||
**Layer 1**
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`MaterialiseAlarmCondition`,
|
||||
`WriteAlarmCondition`, `AlarmConditionDelta`/`ReadConditionDelta`/`ToConditionDelta`)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` (`ToSnapshot` — Quality=Good, or rely on default)
|
||||
|
||||
**Layer 2**
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||
|
||||
## Tests (TDD, RED-first)
|
||||
|
||||
1. **Wire-level (the issue's suggested guard)** — extend `NativeAlarmEventIdentityFieldDeliveryTests`
|
||||
(OpcUaServer.IntegrationTests): active alarm → event `Quality.IsGood`; driver disconnect → condition
|
||||
event `Quality.IsGood == false`; reconnect → Good. Verify RED against pre-fix.
|
||||
2. **Node-level** — `NodeManagerAlarmSourceFieldsTests`: materialise sets Quality (native
|
||||
`BadWaitingForInitialData`, scripted `Good`); `WriteAlarmCondition` projects snapshot quality and
|
||||
fires on a quality-bucket change only.
|
||||
3. **`NativeAlarmProjector`** unit: `ProjectQuality` keeps Active/Acked + swaps quality; `Project`
|
||||
preserves quality.
|
||||
4. **`DriverInstanceActor`**: `Connected` entry Tells `ConnectivityChanged(true)`; `DisconnectObserved`
|
||||
Tells `ConnectivityChanged(false)`.
|
||||
5. **`DriverHostActor`**: `ConnectivityChanged(false)` pushes a Bad-quality `AlarmStateUpdate` to every
|
||||
condition of that driver instance.
|
||||
|
||||
## Deferred / notes
|
||||
|
||||
- **Layer 3** (scripted worst-of-input quality) → **Gitea #478**.
|
||||
- **Implementation note:** L2 uses a **dedicated `IOpcUaAddressSpaceSink.WriteAlarmQuality`** path (not a
|
||||
full-snapshot re-projection). Rationale: a connectivity change must set *only* Quality; re-projecting a full
|
||||
snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a
|
||||
transition. The new sink method is forwarded through `DeferredAddressSpaceSink` (the F10b inertness trap) —
|
||||
auto-verified by `DeferredSinkForwardingReflectionTests` (reflection guard) + its realm-discriminator guard.
|
||||
- **Test-harness note:** the new `DriverInstanceActor → parent` `ConnectivityChanged` Tell polluted existing
|
||||
parent-`TestProbe` assertions in 3 `DriverInstanceActor*Tests` files; those tests now
|
||||
`parent.IgnoreMessages(m => m is ConnectivityChanged)` since they assert on data/alarm/discovery forwards,
|
||||
not connectivity.
|
||||
- `Bad_NoCommunication` vs generic `Bad`: v1 maps `OpcUaQuality.Bad → StatusCodes.Bad`; refining
|
||||
`StatusFromQuality` to emit `BadNoCommunication` for the comms-loss case is a one-line nicety, noted in
|
||||
the issue.
|
||||
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
|
||||
semantics, annotation-not-state-change, quality-bucket change fires an event).
|
||||
|
||||
## Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17)
|
||||
|
||||
**Problem.** A scripted alarm is computed from one or more input tags. Its condition should report the
|
||||
**worst** quality of those inputs ("can I trust this condition's state?"), not the hardcoded `Good` Layer 1
|
||||
left at `ScriptedAlarmHostActor.ToSnapshot`.
|
||||
|
||||
**Two blockers discovered in the live path (both silently discard quality):**
|
||||
1. `DependencyMuxActor.OnAttributeValuePublished` builds `VirtualTagActor.DependencyValueChanged` **without**
|
||||
the `AttributeValuePublished.Quality` it already carries.
|
||||
2. `ScriptedAlarmHostActor.OnDependencyChanged` pushes each mux value into the engine's upstream with a
|
||||
**hardcoded `0u` (Good)** StatusCode.
|
||||
So even a `Bad` driver value reached the scripted engine as Good — Layer 3 has to plumb quality first.
|
||||
|
||||
**Design (mirrors Layer 2's native OT semantic through the scripted channel):**
|
||||
- **Plumb quality end-to-end.** `DependencyValueChanged` gains `OpcUaQuality Quality` (defaulted `Good`, so the
|
||||
virtual-tag engine's calls are unchanged); the mux forwards `msg.Quality`; `OnDependencyChanged` maps it to a
|
||||
StatusCode (`Good→0`, `Uncertain→0x40000000`, `Bad→0x80000000`) on the pushed `DataValueSnapshot`.
|
||||
- **Engine computes the worst input quality** each evaluation (over the refilled read cache, **before** the
|
||||
`AreInputsReady` short-circuit so a `Bad` input is still observed) and carries it as
|
||||
`ScriptedAlarmEvent.WorstInputStatusCode` (a raw `uint` StatusCode — `Core.ScriptedAlarms` doesn't reference
|
||||
Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it to `OpcUaQuality`).
|
||||
- **Transitions carry the current worst quality** → `ToSnapshot` projects it (no clobber-back-to-Good when a
|
||||
transition fires while an input is `Uncertain`).
|
||||
- **Quality-only changes emit out of band.** A `Bad` input freezes the condition (`AreInputsReady` returns
|
||||
false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The
|
||||
engine tracks the last worst-quality **bucket** per alarm and, when the bucket changes with **no** transition
|
||||
emission, emits a new `EmissionKind.QualityChanged` event. The host routes that to the **existing** Layer 2
|
||||
`OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQuality` path (sets ONLY Quality,
|
||||
one Part 9 event on a bucket change, **no `/alerts` row**, no historian write). No new sink surface.
|
||||
- **`ScriptedAlarmSource` (the `IAlarmSource` fan-out adapter) skips `QualityChanged`** — quality is delivered
|
||||
through the dedicated node path, never as a phantom `AlarmEventArgs` (which would materialize/historize a
|
||||
native condition).
|
||||
|
||||
**Files (Layer 3):**
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — `EmissionKind.QualityChanged`.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — worst-of-input, bucket tracking,
|
||||
`WorstInputStatusCode` on the event, quality-only emission.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — skip `QualityChanged`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — `DependencyValueChanged.Quality`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — forward `msg.Quality`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — push real quality;
|
||||
`ToSnapshot` maps `WorstInputStatusCode`; `OnEngineEmission` routes `QualityChanged → AlarmQualityUpdate`.
|
||||
|
||||
**Tests (RED-first):** engine — transition carries `Uncertain` worst; `Bad` input with no transition emits
|
||||
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; no spurious emit when the bucket is unchanged.
|
||||
`ScriptedAlarmSource` — `QualityChanged` raises no `OnAlarmEvent`. Mux — `DependencyValueChanged` carries the
|
||||
published quality. Host — `Bad` dependency → `AlarmQualityUpdate(Bad)`, no `/alerts` publish; `ToSnapshot`
|
||||
maps the event's worst quality.
|
||||
|
||||
**Coverage boundary → Layer 4 (#481).** L3 covers inputs whose driver **publishes a Bad/Uncertain-status
|
||||
data change** (the mux quality path). It does **not** cover a driver **comms loss**: a poll driver
|
||||
(Modbus/S7) whose device goes unreachable emits only `ConnectivityChanged` and goes silent on the value feed
|
||||
(`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||
condition stays Good — the same silent-feed problem native solved in L2, but native's `OnDriverConnectivityChanged`
|
||||
bridge fans only to **native** condition nodes (`_alarmNodeIdByDriverRef`), not into the mux the scripted
|
||||
engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start
|
||||
asymmetry (a runtime `Bad` with a null value is currently indistinguishable from cold start and contributes
|
||||
`Good`) and its ripple into virtual-tag quality — is **Gitea #481 (Layer 4)**. Found by the post-implementation
|
||||
code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality).
|
||||
@@ -0,0 +1,706 @@
|
||||
# OtOpcUa LocalDb Adoption — Phase 1 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
>
|
||||
> **Execution model:** This plan is optimized for **Claude Opus** agents (`claude --model opus`).
|
||||
> Dispatch implementer subagents on Opus for every task classified `high-risk`; `small`/`trivial`
|
||||
> tasks may use Sonnet. Work on branch **`feat/localdb-phase1`** in this repo (`~/Desktop/OtOpcUa`,
|
||||
> remote `lmxopcua`). Do NOT merge to `master` as part of this plan — stop at the DoD task and
|
||||
> report.
|
||||
>
|
||||
> **Design authority:** `~/Desktop/scadaproj/docs/plans/2026-07-20-otopcua-localdb-design.md`.
|
||||
> Read it before Task 0. The reference adoption is ScadaBridge (merged PR #23) — when in doubt,
|
||||
> mirror `~/Desktop/ScadaBridge` (files named per task below).
|
||||
|
||||
**Goal:** Give every driver-role OtOpcUa node a consolidated `ZB.MOM.WW.LocalDb` database that caches
|
||||
the deployed-configuration artifact (chunked), boots from that cache when central SQL Server is
|
||||
unreachable, and optionally replicates it to the node's redundant pair peer over the library's gRPC
|
||||
sync (default-OFF, fail-closed bearer auth).
|
||||
|
||||
**Architecture:** `AddZbLocalDb`/`AddZbLocalDbReplication` wired in a new `LocalDbRegistration`
|
||||
(mirroring `SecretsRegistration`), DDL + `RegisterReplicated` in `LocalDbSetup.OnReady`, a dedicated
|
||||
Kestrel h2c listener for `MapZbLocalDbSync` gated on `LocalDb:SyncListenPort`, a consumer-supplied
|
||||
fail-closed bearer interceptor, and an `IDeploymentArtifactCache` seam written by `DriverHostActor`
|
||||
after each successful apply and read as a boot fallback. The dormant LiteDB LocalCache subsystem is
|
||||
deleted as superseded.
|
||||
|
||||
**Tech stack:** .NET 10, `ZB.MOM.WW.LocalDb`/`.Replication`/`.Contracts` **0.1.1** (Gitea feed),
|
||||
`Grpc.AspNetCore` 2.76.0, Akka.NET (existing), xunit.v3 (Host.IntegrationTests) + xunit2 TestKit
|
||||
(actor tests).
|
||||
|
||||
**Hard rules (from the ScadaBridge adoption — violating any of these is a defect):**
|
||||
1. Order inside `OnReady` is load-bearing: **DDL → `RegisterReplicated` → writes**. Rows written
|
||||
before registration are never captured or snapshotted — silently, forever.
|
||||
2. No autoincrement PKs, no BLOB columns in replicated tables.
|
||||
3. The library's passive sync endpoint verifies **no** ApiKey — the host interceptor is the only
|
||||
auth, and it must be fail-closed (no key configured ⇒ deny everything).
|
||||
4. `ILocalDb.CreateConnection()` returns an **already-open**, pragma-configured connection with the
|
||||
`zb_hlc_next()` UDF. Never call `Open()` on it. There is **no in-memory mode** — tests use temp
|
||||
files + `SqliteConnection.ClearAllPools()` before delete.
|
||||
5. `Grpc.Core.Testing` does not exist on grpc-dotnet — hand-roll fake `ServerCallContext`s.
|
||||
6. Explicit Kestrel `Listen*` calls make Kestrel **ignore `ASPNETCORE_URLS`** — when adding the sync
|
||||
listener you must re-bind the existing HTTP port in the same block, and verify it on the rig.
|
||||
7. Never run host `sqlite3` against a live bind-mounted WAL DB (copy the `db`/`-wal`/`-shm` triplet
|
||||
with `cp` instead). `aspnet:10.0` containers have no `curl` — use a
|
||||
`curlimages/curl` sidecar with `--network container:<name>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Preflight + recon (produces `docs/plans/2026-07-20-localdb-phase1-recon.md`)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (everything downstream consumes its findings)
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/plans/2026-07-20-localdb-phase1-recon.md`
|
||||
- Read-only: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`,
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`,
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`,
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs`,
|
||||
`Directory.Packages.props`, `docker-dev/docker-compose.yml`,
|
||||
the observability registration (`AddOtOpcUaObservability` implementation),
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/*`
|
||||
|
||||
**Step 1: Verify the feed packages restore.**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd ~/Desktop/OtOpcUa && dotnet package search ZB.MOM.WW.LocalDb --source dohertj2-gitea --format json | head -50
|
||||
```
|
||||
Expected: `ZB.MOM.WW.LocalDb`, `.Replication`, `.Contracts` at `0.1.1`. If absent → STOP, report.
|
||||
|
||||
**Step 2: Map and record, with `file:line` citations, each of:**
|
||||
1. **Deploy fetch path:** where `DriverHostActor` loads `Deployment.ArtifactBlob` (the
|
||||
`_dbFactory.CreateDbContext()` sites, ~L1472/1543), the artifact's runtime type
|
||||
(`DeploymentArtifact` — how it's deserialized from the blob), the type/format of
|
||||
`DeploymentId` and `RevisionHash`, and where a successful apply completes (the point after
|
||||
which the cache write belongs).
|
||||
2. **Cold-boot path:** what the actor does at startup before any `DispatchDeployment` arrives —
|
||||
does it query central SQL for the current deployment? Where does the failure path land
|
||||
(the `Stale` state, `DbHealthProbeActor` interaction, L1345 dispatch-ignore)? Identify the
|
||||
exact seam where "central fetch failed at boot" is known — that is where the cache fallback goes.
|
||||
3. **Cluster identity:** how a driver node knows its `ClusterId` (config key / `ClusterNode` row /
|
||||
`IClusterRoleInfo`) — the cache is keyed by it.
|
||||
4. **DriverHostActor construction:** how its dependencies are injected
|
||||
(`WithOtOpcUaRuntimeActors`, `Props` wiring) so `IDeploymentArtifactCache` can be threaded in.
|
||||
⚠ `Props.Create` builds an expression tree — adding a parameter silently rebinds
|
||||
out-of-position named args at call sites; list every construction site.
|
||||
5. **Telemetry allowlist:** does `AddOtOpcUaObservability` restrict meters
|
||||
(`ZbTelemetryOptions.Meters` or equivalent)? If yes, record where the list lives.
|
||||
6. **Kestrel/URLs:** confirm the Host binds via `ASPNETCORE_URLS=http://+:9000` with no
|
||||
`ConfigureKestrel` calls; record any existing `builder.WebHost` usage.
|
||||
7. **LiteDB LocalCache:** confirm (grep) that nothing outside
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/` and its tests references
|
||||
`ILocalConfigCache`/`GenerationSealedCache`/`ResilientConfigReader`/`LiteDB`.
|
||||
8. **docker-dev volumes:** whether nodes have a writable data volume; record what `LocalDb:Path`
|
||||
should be per node and how env vars are passed (`Cluster__*` style double-underscore).
|
||||
|
||||
**Step 3: STOP conditions — halt the plan and report instead of improvising if:**
|
||||
- the artifact apply path cannot be given a post-apply hook without restructuring the actor;
|
||||
- `DeploymentId`/cluster identity are not stable strings/GUIDs;
|
||||
- LiteDB LocalCache turns out to be referenced by live code.
|
||||
|
||||
**Step 4: Commit.**
|
||||
```bash
|
||||
git add docs/plans/2026-07-20-localdb-phase1-recon.md
|
||||
git commit -m "docs(localdb): phase-1 recon findings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package references and pins
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (all code tasks build on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj`
|
||||
|
||||
**Step 1:** Add to `Directory.Packages.props` (versions exact):
|
||||
```xml
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.1" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
```
|
||||
Check the existing `Grpc.Net.Client`/`Grpc.Core.Api` are already ≥ 2.76.0 and
|
||||
`Google.Protobuf` ≥ 3.34.1 (the LocalDb.Replication nuspec floors); raise if not. Confirm a
|
||||
`SQLitePCLRaw` pin ≥ 2.1.12 exists for the transitive `Microsoft.Data.Sqlite` chain (the family
|
||||
advisory GHSA-2m69-gcr7-jv3q); Core.AlarmHistorian already pins `bundle_e_sqlite3` 2.1.12 — add
|
||||
`SQLitePCLRaw.lib.e_sqlite3` 2.1.12 as an explicit reference in the Host if the audit flags it.
|
||||
|
||||
**Step 2:** Host csproj: `ZB.MOM.WW.LocalDb`, `ZB.MOM.WW.LocalDb.Replication`, `Grpc.AspNetCore`.
|
||||
Runtime csproj: `ZB.MOM.WW.LocalDb` only (it needs `ILocalDb`, not the sync engine).
|
||||
|
||||
**Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings/errors (repo builds warnings-as-errors).
|
||||
|
||||
**Step 4: Commit.** `git commit -m "build(localdb): reference ZB.MOM.WW.LocalDb 0.1.1 + Grpc.AspNetCore"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Schema + `LocalDbSetup.OnReady` + registration tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/DeploymentCacheSchema.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/LocalDbSetupTests.cs` (create the test project
|
||||
reference layout to match neighboring Host unit tests; if Host has no unit-test project, place in
|
||||
the closest existing one and note the deviation)
|
||||
|
||||
**Step 1: Write the failing tests first** — using a real temp-file `ILocalDb` (build it via
|
||||
`new ServiceCollection().AddZbLocalDb(config, LocalDbSetup.OnReady)` with `LocalDb:Path` pointed at
|
||||
a temp file; dispose + `SqliteConnection.ClearAllPools()` in cleanup):
|
||||
- `OnReady_RegistersExactlyTheTwoDeploymentTables` — `db.ReplicatedTables.Keys` ordinal-sorted
|
||||
equals `["deployment_artifacts", "deployment_pointer"]` (assert BOTH directions: no fewer, no more).
|
||||
- `DeploymentArtifacts_PkIsDeploymentIdPlusChunkIndex` and `DeploymentPointer_PkIsClusterId`
|
||||
(pin `ReplicatedTable.PkColumns`).
|
||||
- `RowsWrittenAfterOnReady_EnterTheOplog` — insert a row, assert `SELECT COUNT(*) FROM __localdb_oplog` ≥ 1
|
||||
(pins the DDL→register ordering; this is the assertion that catches a silently-broken CDC).
|
||||
|
||||
**Step 2: Run tests, watch them fail** (types don't exist yet).
|
||||
|
||||
**Step 3: Implement.** `DeploymentCacheSchema` depends only on `Microsoft.Data.Sqlite` (the
|
||||
ScadaBridge `*Schema.Apply` pattern — self-sufficient for direct construction in tests):
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Deployment;
|
||||
|
||||
public static class DeploymentCacheSchema
|
||||
{
|
||||
public static void Apply(SqliteConnection connection)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS deployment_artifacts (
|
||||
deployment_id TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
cluster_id TEXT NOT NULL,
|
||||
revision_hash TEXT NOT NULL,
|
||||
chunk_count INTEGER NOT NULL,
|
||||
chunk_base64 TEXT NOT NULL,
|
||||
cached_at_utc TEXT NOT NULL,
|
||||
PRIMARY KEY (deployment_id, chunk_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deployment_pointer (
|
||||
cluster_id TEXT NOT NULL PRIMARY KEY,
|
||||
deployment_id TEXT NOT NULL,
|
||||
revision_hash TEXT NOT NULL,
|
||||
artifact_sha256 TEXT NOT NULL,
|
||||
applied_at_utc TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`LocalDbSetup` (Host):
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
internal static class LocalDbSetup
|
||||
{
|
||||
// ORDER IS LOAD-BEARING: DDL, then RegisterReplicated, then (nothing else in Phase 1).
|
||||
// Rows written before RegisterReplicated are invisible to the peer forever, silently.
|
||||
public static void OnReady(ILocalDb db)
|
||||
{
|
||||
using var connection = db.CreateConnection(); // already open — do NOT call Open()
|
||||
DeploymentCacheSchema.Apply(connection);
|
||||
db.RegisterReplicated("deployment_artifacts");
|
||||
db.RegisterReplicated("deployment_pointer");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4:** `dotnet test --filter "FullyQualifiedName~LocalDbSetupTests"` → PASS.
|
||||
|
||||
**Step 5: Commit.** `git commit -m "feat(localdb): deployment-cache schema + OnReady registration"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fail-closed sync auth interceptor + tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/LocalDbSyncAuthInterceptorTests.cs`
|
||||
|
||||
Port ScadaBridge's `src/ZB.MOM.WW.ScadaBridge.Host/LocalDbSyncAuthInterceptor.cs` (read it first;
|
||||
keep behavior identical, adjust namespace only). Semantics to preserve exactly:
|
||||
- `ServicePrefix = "/localdb_sync.v1.LocalDbSync/"`; any other method path passes through untouched.
|
||||
- Expected token from `IOptions<ReplicationOptions>.Value.ApiKey`.
|
||||
- **Fail-closed:** null/empty configured key ⇒ `RpcException(new Status(StatusCode.PermissionDenied, ...))`
|
||||
for every sync call. Missing/mismatched bearer ⇒ same.
|
||||
- `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes; header `authorization`,
|
||||
case-insensitive, `"Bearer "` prefix. Override all four server handler kinds.
|
||||
|
||||
**Tests** (hand-rolled fake `ServerCallContext` — `Grpc.Core.Testing` does not exist on grpc-dotnet;
|
||||
copy ScadaBridge's `LocalDbSyncAuthInterceptorTests.cs` fake): non-sync method passes with no key;
|
||||
sync + no configured key → PermissionDenied; wrong bearer → PermissionDenied; correct bearer → passes.
|
||||
|
||||
Write tests first (fail), implement, `dotnet test --filter "FullyQualifiedName~LocalDbSyncAuthInterceptor"`,
|
||||
commit `feat(localdb): fail-closed bearer interceptor for the sync endpoint`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `LocalDbRegistration` + Program.cs DI wiring + config defaults
|
||||
|
||||
**Classification:** high-risk (touches Program.cs / role gating)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 5 edits the same file)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (inside the `hasDriver` branch)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json`
|
||||
|
||||
**Step 1:** `LocalDbRegistration`, mirroring `SecretsRegistration`'s shape:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
internal static class LocalDbRegistration
|
||||
{
|
||||
/// Driver-role nodes only. Storage is unconditional; replication stays inert until
|
||||
/// LocalDb:Replication:PeerAddress (initiator) / LocalDb:SyncListenPort (listener) are set.
|
||||
public static IServiceCollection AddOtOpcUaLocalDb(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddZbLocalDb(configuration, LocalDbSetup.OnReady);
|
||||
services.AddZbLocalDbReplication(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
public static int SyncListenPort(IConfiguration configuration) =>
|
||||
configuration.GetValue<int>("LocalDb:SyncListenPort");
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** Program.cs, in the `hasDriver` block (near `AddAlarmHistorian`):
|
||||
`builder.Services.AddOtOpcUaLocalDb(builder.Configuration);` and
|
||||
`builder.Services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());`
|
||||
(AddGrpc only under `hasDriver` — admin-only nodes expose no sync surface).
|
||||
|
||||
**Step 3:** appsettings.json:
|
||||
```json
|
||||
"LocalDb": {
|
||||
"Path": "./data/otopcua-localdb.db",
|
||||
"SyncListenPort": 0,
|
||||
"Replication": {}
|
||||
}
|
||||
```
|
||||
`LocalDb:Path` is `ValidateOnStart`-required once `AddZbLocalDb` runs — since registration is
|
||||
driver-gated, admin-only configs need nothing. Check the role-overlay files
|
||||
(`appsettings.driver.json` / `appsettings.admin-driver.json`) and any deploy templates for
|
||||
conflicting `LocalDb` keys.
|
||||
|
||||
**Step 4:** Build + full Host unit tests. Verify an admin-only graph doesn't require the key: this
|
||||
is pinned properly in Task 10's integration tests, but do a quick
|
||||
`OTOPCUA_ROLES=admin dotnet run` smoke only if cheap; otherwise rely on Task 10.
|
||||
|
||||
**Step 5: Commit.** `feat(localdb): wire AddOtOpcUaLocalDb into the driver role`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dedicated h2c sync listener + endpoint mapping (THE Kestrel task)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Program.cs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
|
||||
|
||||
**Why high-risk:** the Host today binds exclusively via `ASPNETCORE_URLS=http://+:9000`. Any
|
||||
explicit `ConfigureKestrel(... Listen*)` makes Kestrel **ignore URLs entirely** (it logs
|
||||
"Overriding address(es)"), which would silently kill the AdminUI/deploy API behind Traefik.
|
||||
|
||||
**Step 1:** Add, before `builder.Build()`:
|
||||
```csharp
|
||||
var syncPort = LocalDbRegistration.SyncListenPort(builder.Configuration);
|
||||
if (hasDriver && syncPort > 0)
|
||||
{
|
||||
// Explicit Listen* replaces ASPNETCORE_URLS wholesale, so re-bind the primary HTTP
|
||||
// endpoint here too. Parse it from the configured URLs rather than hard-coding 9000.
|
||||
var urls = builder.Configuration["ASPNETCORE_URLS"] ?? builder.Configuration["urls"] ?? "http://+:9000";
|
||||
var httpPort = new Uri(urls.Split(';')[0].Replace("+", "localhost").Replace("*", "localhost")).Port;
|
||||
builder.WebHost.ConfigureKestrel(k =>
|
||||
{
|
||||
k.ListenAnyIP(httpPort); // HTTP/1.1 (existing surface)
|
||||
k.ListenAnyIP(syncPort, o => o.Protocols = HttpProtocols.Http2); // h2c, prior-knowledge gRPC
|
||||
});
|
||||
}
|
||||
```
|
||||
When `syncPort == 0` (the default) nothing changes — URLs binding stays untouched. Cleartext
|
||||
`Http1AndHttp2` cannot serve prior-knowledge h2c, hence the dedicated `Http2`-only listener.
|
||||
|
||||
**Step 2:** After the app pipeline's other `Map*` calls, driver-gated:
|
||||
```csharp
|
||||
if (hasDriver && syncPort > 0)
|
||||
{
|
||||
app.MapZbLocalDbSync();
|
||||
}
|
||||
```
|
||||
(Mapping is harmless when unauthenticated — the interceptor fail-closes — but gating on the port
|
||||
keeps admin-only and default-OFF graphs entirely free of the endpoint.)
|
||||
|
||||
**Step 3: Verify both surfaces.**
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
```
|
||||
Then a local smoke with the port on:
|
||||
`ASPNETCORE_URLS=http://+:9000 OTOPCUA_ROLES=driver LocalDb__SyncListenPort=9001 dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host ...`
|
||||
— expect startup logs to show BOTH `:9000` and `:9001` bound, and `curl -s localhost:9000/healthz`
|
||||
(or the mapped health route) still answering. If the app needs SQL to boot, defer the smoke to the
|
||||
Task 12 rig check but say so explicitly in the task notes.
|
||||
|
||||
**Step 4: Commit.** `feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `IDeploymentArtifactCache` + chunked LocalDb implementation + tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs`
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs`
|
||||
|
||||
**Step 1: Failing tests** (real temp-file `ILocalDb` with `LocalDbSetup.OnReady` applied):
|
||||
- round-trip: store a 300 KiB random artifact → `GetCurrentAsync` returns byte-identical payload
|
||||
(forces ≥ 3 chunks at 128 KiB);
|
||||
- retention: store 3 deployments for one cluster → only the newest 2 remain in
|
||||
`deployment_artifacts`; pointer names the newest;
|
||||
- integrity: corrupt one chunk row via SQL → `GetCurrentAsync` returns null (miss), never a
|
||||
truncated artifact;
|
||||
- missing pointer → null.
|
||||
|
||||
**Step 2: Implement:**
|
||||
```csharp
|
||||
public interface IDeploymentArtifactCache
|
||||
{
|
||||
Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
||||
byte[] artifact, CancellationToken ct = default);
|
||||
Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record CachedDeploymentArtifact(
|
||||
string DeploymentId, string RevisionHash, byte[] Artifact, DateTimeOffset AppliedAtUtc);
|
||||
```
|
||||
`LocalDbDeploymentArtifactCache(ILocalDb db)`:
|
||||
- `StoreAsync`: one `ILocalDbTransaction` — delete any existing chunks for this `deployment_id`
|
||||
(idempotent re-store), insert chunks (raw 128 * 1024 bytes per chunk, `Convert.ToBase64String`),
|
||||
upsert the pointer (`INSERT ... ON CONFLICT(cluster_id) DO UPDATE`), then prune: delete
|
||||
`deployment_artifacts` rows whose `deployment_id` is not among the newest 2 `cached_at_utc` for
|
||||
this `cluster_id`. SHA-256 over the raw artifact into the pointer. ISO-8601 UTC timestamps.
|
||||
- `GetCurrentAsync`: read pointer; read chunks `ORDER BY chunk_index`; verify count ==
|
||||
`chunk_count` and SHA-256 matches; return null on any mismatch (log a warning naming which check
|
||||
failed).
|
||||
- Use `db.ExecuteAsync`/`QueryAsync` with anonymous-object parameters (`@Name` markers). Remember
|
||||
a `Dictionary<string,string>` parameter **throws** — use anonymous objects.
|
||||
|
||||
**Step 3:** run tests → PASS. **Step 4: Commit.**
|
||||
`feat(localdb): chunked deployment-artifact cache over ILocalDb`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Cache write path in `DriverHostActor`
|
||||
|
||||
**Classification:** high-risk (actor model, `Props` expression-tree trap)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** (exact edit sites come from the Task 0 recon doc — cite it in the commit)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||
- Modify: the actor-registration site (`WithOtOpcUaRuntimeActors` / DI extension) to provide
|
||||
`IDeploymentArtifactCache`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` or `LocalDbRegistration` — register
|
||||
`IDeploymentArtifactCache` → `LocalDbDeploymentArtifactCache` singleton (driver branch)
|
||||
- Test: the **xunit2** TestKit project used for existing DriverHostActor tests (recon names it)
|
||||
|
||||
**Steps:**
|
||||
1. **Failing test:** after a successful artifact apply, the cache received `StoreAsync` with the
|
||||
applied deployment's id/hash/bytes (inject a recording fake `IDeploymentArtifactCache`).
|
||||
Also: a cache that throws on `StoreAsync` does NOT fail the apply (apply result unchanged, error
|
||||
logged).
|
||||
2. Thread the dependency through the actor's constructor. ⚠ `Props.Create` is an expression tree:
|
||||
after adding the parameter, re-check **every** construction site for positional/named-arg
|
||||
rebinding (the recon lists them) — do not rely on the compiler.
|
||||
3. Invoke `StoreAsync` fire-and-forget (`PipeTo`-style or a guarded `Task.Run` per the actor's
|
||||
existing async conventions — match whatever pattern the actor already uses for side-effect IO)
|
||||
at the post-apply point identified in recon. Cluster id from the recon-identified source.
|
||||
4. Run the actor test suite for this project. Commit:
|
||||
`feat(localdb): DriverHostActor stores applied artifacts in the pair-local cache`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Boot-from-cache read path + running-from-cache signal
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||
- Test: same xunit2 TestKit project as Task 7
|
||||
|
||||
**Steps:**
|
||||
1. **Failing tests:**
|
||||
- central fetch fails at startup AND cache has an artifact → the actor applies the cached
|
||||
artifact (assert via whatever observable the apply already exposes) and logs/flags
|
||||
running-from-cache;
|
||||
- central fetch fails AND cache empty → today's behavior exactly (Stale, no apply);
|
||||
- central fetch **succeeds** → cache is NOT consulted (fresh config wins; assert the fake
|
||||
cache's `GetCurrentAsync` was never called on the happy path).
|
||||
2. Implement at the recon-identified boot-failure seam. The signal: a log warning at minimum plus,
|
||||
if the actor already publishes health/status (recon says how), a `RunningFromCache` marker on it.
|
||||
Do NOT touch `DispatchDeployment` handling — a new deployment still requires central.
|
||||
3. Run tests; commit `feat(localdb): boot from the pair-local artifact cache when central SQL is unreachable`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Delete the dormant LiteDB LocalCache subsystem
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 6, Task 10, Task 11
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/` (entire directory:
|
||||
`ILocalConfigCache`, `LiteDbConfigCache`, `GenerationSealedCache`, `ResilientConfigReader`,
|
||||
`GenerationSnapshot`, `StaleConfigFlag`, exceptions)
|
||||
- Delete: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs`,
|
||||
`ResilientConfigReaderTests.cs`
|
||||
- Modify: `Directory.Packages.props` + the Configuration csproj — remove the `LiteDB` package if
|
||||
nothing else references it (grep first)
|
||||
|
||||
**Steps:** Confirm the Task 0 recon's "nothing references it" finding still holds
|
||||
(`grep -rn "ILocalConfigCache\|GenerationSealedCache\|ResilientConfigReader\|LiteDB" src/ tests/`),
|
||||
delete, build the full solution, run the Configuration test project. DoD phrasing: **no references
|
||||
from code** — leave any explanatory prose/comments that point readers at LocalDb instead. Add one
|
||||
line to the recon doc noting LiteDB's removal. Commit:
|
||||
`refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb)`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Health check + telemetry meter allowlist
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9, Task 11
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/LocalDbReplicationHealthCheck.cs` (or the
|
||||
directory `AddOtOpcUaHealth` uses — recon names it)
|
||||
- Modify: the `AddOtOpcUaHealth` registration (driver branch)
|
||||
- Modify: the observability meter list IF Task 0 found an allowlist
|
||||
|
||||
**Steps:**
|
||||
1. Failing unit tests: replication unconfigured (no peer, no listener) → `Healthy` (default-OFF
|
||||
must not degrade a plain node); peer configured + `ISyncStatus.Connected == false` → `Degraded`;
|
||||
connected + backlog `null` → `Degraded` (unknown backlog is not healthy); connected + backlog
|
||||
small → `Healthy`.
|
||||
2. Implement against `ISyncStatus` + `IOptions<ReplicationOptions>` (peer-configured = non-empty
|
||||
`PeerAddress` OR `SyncListenPort > 0` — pass the latter in via options/config).
|
||||
3. **Meter allowlist:** if `AddOtOpcUaObservability` restricts meters, add
|
||||
`"ZB.MOM.WW.LocalDb.Replication"` (use `LocalDbMetrics.MeterName`) — this is a silent allowlist;
|
||||
the ScadaBridge live gate is the proof it bites. If there is no allowlist, record that in the
|
||||
recon doc and skip.
|
||||
4. Commit `feat(localdb): replication health check + meter export`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: DI-pin integration tests over the real Program.cs
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 9, Task 10
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs` (xunit.v3,
|
||||
`WebApplicationFactory<Program>` — the project already builds the real Program.cs; set
|
||||
`OTOPCUA_ROLES` per test case the way neighboring tests do)
|
||||
|
||||
**Pins (each its own test):**
|
||||
1. Driver graph: `ILocalDb` resolves, is a singleton, `ReplicatedTables` ordinal-sorted ==
|
||||
`["deployment_artifacts", "deployment_pointer"]` — exact set, both directions load-bearing.
|
||||
2. Driver graph: `IDeploymentArtifactCache` resolves to `LocalDbDeploymentArtifactCache`.
|
||||
3. Default-OFF pin: `ISyncStatus` resolves with `Connected == false`, `PeerNodeId == null`.
|
||||
4. Admin-only graph: `ILocalDb` is NOT registered (`GetService<ILocalDb>()` is null) and boot does
|
||||
not demand `LocalDb:Path`.
|
||||
5. Health: the LocalDb health check is registered in the driver graph.
|
||||
|
||||
Point `LocalDb:Path` at a per-test temp file via the factory's config overrides;
|
||||
`ClearAllPools` + delete in cleanup. These tests exist because DI extensions without a
|
||||
container-built test have shipped inert three times in this family (Secrets 0.2.0/0.2.2,
|
||||
ScadaBridge#22). Commit `test(localdb): DI pins over the real host graph`.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Two-node convergence harness + tests
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min (harness) + ~4 min (scenarios) — split the commit if needed
|
||||
**Parallelizable with:** none (depends on Tasks 2, 3, 5, 6)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairConvergenceTests.cs`
|
||||
|
||||
**Harness:** copy the pattern from
|
||||
`~/Desktop/ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs` and
|
||||
the library's `ConvergenceFixture` (`~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/tests/.../Convergence/ConvergenceFixture.cs`):
|
||||
- Two full stacks over a real loopback Kestrel h2c socket (port 0), **through the real
|
||||
`LocalDbSyncAuthInterceptor`** with a shared test key; node A initiator, node B passive.
|
||||
- Both initialized via the **production `LocalDbSetup.OnReady`** — a hand-written schema proves
|
||||
only that the test agrees with itself.
|
||||
- DBs owned by the harness, registered as pre-constructed singletons (so MS.DI won't dispose them
|
||||
on host teardown); `KillTransportAsync`/`RestartTransportAsync`; tight `FlushInterval` (50 ms),
|
||||
bounded backoff (2 s); temp files, `ClearAllPools` cleanup; a collection definition to serialize
|
||||
these tests.
|
||||
|
||||
**Scenarios:**
|
||||
1. `ArtifactStoredOnA_ConvergesToB_ByteIdentical` — store a multi-chunk artifact via
|
||||
`LocalDbDeploymentArtifactCache` on A; B's cache returns byte-identical bytes; both nodes'
|
||||
`__localdb_row_version` rows for the pointer carry the **same HLC and origin node id** (B holds
|
||||
A's row, not a re-derived one).
|
||||
2. `RetentionPruneOnA_TombstonesReachB` — third deployment on A prunes the first; B's chunk rows
|
||||
for the pruned deployment disappear and `__localdb_row_version WHERE is_tombstone=1` rows exist
|
||||
on B.
|
||||
3. `WritesWhileTransportDown_SurviveRejoin` — kill transport, store on A, restart, converge.
|
||||
4. `WrongApiKey_NeverConverges` — harness with mismatched keys: assert **no** convergence after a
|
||||
bounded wait AND (positive control) that the same scenario with matching keys converges — an
|
||||
absence assertion without a positive control passed vacuously in ScadaBridge.
|
||||
|
||||
**DoD within this task:** temporarily comment out the two `RegisterReplicated` calls and confirm
|
||||
scenarios 1–3 go red (run locally, do not commit the red state); restore. Record the red/green
|
||||
evidence in the task notes. Commit `test(localdb): 2-node convergence harness + scenarios`.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: docker-dev rig configuration
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 14
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-dev/docker-compose.yml`
|
||||
|
||||
**Steps** (adjust to the recon's findings on volumes/env style):
|
||||
1. Every driver node: a named volume (or existing data mount) backing `/app/data`, and
|
||||
`LocalDb__Path=/app/data/otopcua-localdb.db`.
|
||||
2. **site-a pair only** (mirror the ScadaBridge default-OFF posture — site-b stays unreplicated as
|
||||
the pin):
|
||||
- site-a-1: `LocalDb__SyncListenPort=9001`, `LocalDb__Replication__PeerAddress=http://site-a-2:9001`,
|
||||
`LocalDb__Replication__ApiKey=dev-site-a-localdb-sync-key`, `LocalDb__Replication__MaxBatchSize=16`
|
||||
- site-a-2: `LocalDb__SyncListenPort=9001`, same `ApiKey`, same `MaxBatchSize`, **no PeerAddress**
|
||||
(passive; the stream is bidirectional).
|
||||
The key must be byte-identical on both — the interceptor fail-closes on any mismatch and the
|
||||
pair silently stops converging.
|
||||
3. `MaxBatchSize=16` because batching is row-count-only against gRPC's 4 MB cap and artifact chunk
|
||||
rows are ≈ 171 KB (16 × 171 KB ≈ 2.7 MB worst case).
|
||||
4. `docker compose config` to validate; do NOT bring the rig up in this task (the live gate task
|
||||
owns rig runs).
|
||||
5. Commit `chore(localdb): rig config — consolidated path everywhere, replication on the site-a pair`.
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Documentation
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 13
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/2026-07-20-localdb-pair-replication.md` (runbook: enable/disable,
|
||||
ApiKey handling via `${secret:}`, stop/start-together rule, tombstone-retention resurrection
|
||||
window, MaxBatchSize row-count-vs-4MB, never host-`sqlite3` a live WAL DB / cp-triplet recipe)
|
||||
- Modify: `docs/Redundancy.md` (a short "pair-local config cache" section: what replicates, what
|
||||
boot-from-cache does and does not cover)
|
||||
- Modify: `CLAUDE.md` (this repo): LocalDb adoption row/paragraph — state Phase 1 scope, default-OFF,
|
||||
rig-only enablement
|
||||
- Modify: `docs/Configuration.md` if it documents config keys (add the `LocalDb` section)
|
||||
|
||||
Commit `docs(localdb): phase-1 runbook + redundancy/config docs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: DoD sweep (offline)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (final offline task)
|
||||
|
||||
**Steps:**
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 warnings** (warnings-as-errors).
|
||||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → full suite green; record counts. Baseline any
|
||||
pre-existing failures BEFORE this branch (`git stash` → run → unstash) rather than assuming;
|
||||
report deltas only.
|
||||
3. Greps (phrase as "no references from code"; explanatory comments may remain):
|
||||
`grep -rn "LiteDB\|ILocalConfigCache\|GenerationSealedCache" src/ tests/` → no code references.
|
||||
4. Confirm the positive-control evidence from Task 12 is recorded.
|
||||
5. Update `docs/plans/2026-07-20-localdb-adoption-phase1.md.tasks.json` statuses.
|
||||
6. Commit `chore(localdb): phase-1 DoD sweep` and STOP. Report: branch name, commit list, test
|
||||
counts, and that the live gate (Task 16) needs the rig + operator go-ahead.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Live gate on the docker-dev rig (run only with explicit user go-ahead)
|
||||
|
||||
**Classification:** high-risk (rig)
|
||||
**Estimated implement time:** ~30 min wall-clock (mostly waiting)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Preamble:** All DB inspection via `cp` of the `db`/`-wal`/`-shm` triplet out of the container
|
||||
(`docker cp`) and querying the copy — never host `sqlite3` on the live file (it poisons the WAL
|
||||
across virtiofs; root cause of the 2026-07-20 ScadaBridge incident). Metrics via
|
||||
`docker run --rm --network container:<node> curlimages/curl:latest -s localhost:<port>/metrics`.
|
||||
If an anomaly appears within seconds of one of your own measurements, suspect the measurement
|
||||
first — restart both nodes and re-run untouched before blaming the library.
|
||||
|
||||
**Checks (all must PASS; record evidence in `docs/plans/2026-07-20-localdb-phase1-live-gate.md`):**
|
||||
1. Rig up, site-a pair healthy, `/metrics` on both shows `localdb_` series (proves the meter
|
||||
allowlist work).
|
||||
2. Deploy a config through the normal deploy API → both site-a nodes' `deployment_pointer` +
|
||||
`deployment_artifacts` rows are byte-identical with **identical HLC + origin node id** (one
|
||||
node's row replicated, not two independent derivations — both nodes also locally store after
|
||||
apply, so expect LWW-converged identical content either way; assert convergence, and note which
|
||||
origin won).
|
||||
3. **Boot-from-cache:** stop the SQL container; restart site-a-2; it comes up serving the cached
|
||||
config with the running-from-cache signal in its log; start SQL again; node recovers fresh
|
||||
behavior.
|
||||
4. **Replicated-cache payoff:** wipe site-a-2's local DB file (container stopped), restart it with
|
||||
SQL **up**, let replication repopulate the cache from site-a-1, then repeat check 3's SQL-down
|
||||
restart — it boots from a cache it never wrote itself.
|
||||
5. Transport kill (pause site-a-2 container): store/deploy on a-1, oplog depth rises; unpause;
|
||||
drains to 0; converged.
|
||||
6. Retention: deploy a 3rd config; pruned deployment's chunks gone on BOTH nodes with tombstone
|
||||
rows present on both.
|
||||
7. Both-nodes-together restart: clean rejoin, identical counts, zero
|
||||
`disk I/O error`/`SQLITE_IOERR`/`corrupt` in either log.
|
||||
8. site-b pair (default-OFF pin): LocalDb file exists and works locally, `ISyncStatus` health
|
||||
Healthy, no sync connections, no listener on 9001.
|
||||
|
||||
Record PASS/FAIL per check with the actual evidence (row counts, md5s, log lines). Commit the gate
|
||||
doc. Do not merge — report.
|
||||
|
||||
---
|
||||
|
||||
## Task persistence
|
||||
|
||||
Tasks file: `docs/plans/2026-07-20-localdb-adoption-phase1.md.tasks.json` (same directory).
|
||||
Update statuses as tasks complete; any deviation from this plan gets a `deviation` note on the task
|
||||
(the ScadaBridge tasks.json convention — the record of *why* is what makes the plan auditable).
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-20-localdb-adoption-phase1.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Preflight + recon (findings doc, STOP conditions)",
|
||||
"status": "completed",
|
||||
"notes": "Feed verified: LocalDb/.Contracts/.Replication all 0.1.1. All 3 STOP conditions clear. Findings: docs/plans/2026-07-20-localdb-phase1-recon.md. 8 deviations recorded (D-1..D-8); D-1/D-3/D-5/D-6 change downstream work materially.",
|
||||
"deviation": "D-1 cache read cannot be keyed by ClusterId at the boot seam (unkeyed newest-pointer read instead); D-3 a third LiteDB test file must be deleted; D-5 WebApplicationFactory<Program> is deliberately unused in this repo (use TwoNodeClusterHarness); D-6 driver-only nodes have no ASPNETCORE_URLS so the Kestrel re-bind fallback is wrong. See recon doc \u00a79."
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Package references and pins (LocalDb 0.1.1, Grpc.AspNetCore, SQLitePCLRaw)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Schema + LocalDbSetup.OnReady + registration tests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Fail-closed sync auth interceptor + tests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: LocalDbRegistration + Program.cs DI wiring + config defaults",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Dedicated h2c sync listener + MapZbLocalDbSync (Kestrel URLs-override risk)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: IDeploymentArtifactCache + chunked LocalDb implementation + tests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Cache write path in DriverHostActor (Props expression-tree trap)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Boot-from-cache read path + running-from-cache signal",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Delete the dormant LiteDB LocalCache subsystem",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Health check + telemetry meter allowlist",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: DI-pin integration tests over the real Program.cs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: Two-node convergence harness + scenarios (+ positive control)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: docker-dev rig configuration (site-a pair on, site-b pin off)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: Documentation (runbook, Redundancy.md, CLAUDE.md)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: DoD sweep (offline) \u2014 STOP and report after this",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14
|
||||
],
|
||||
"notes": "Offline DoD sweep: full-solution build 0 errors (824 pre-existing OTOPCUA0001 analyzer warnings in driver *test* projects; none reference any LocalDb/DeploymentCache file). Grep: no LiteDB/ILocalConfigCache/GenerationSealedCache code refs (2 explanatory doc-comment lines in ILdapGroupRoleMappingService remain, allowed). Runtime.Tests 407/0/31 twice (the previously-flagged intermittent did NOT reproduce). Host.IntegrationTests LocalDb subset 40/0/0. Full-solution `dotnet test` with stash-baseline NOT run: many suites are infra-gated (driver fixtures, full Akka mesh, LDAP real-bind, shared-SQL DB tests) and cannot run offline on macOS \u2014 deferred to Task 16 / CI. Task 12 positive-control evidence recorded in commit afa5be71. Did NOT merge to master \u2014 stopped here per plan."
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16: Live gate on the docker-dev rig (needs explicit user go-ahead)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
15
|
||||
],
|
||||
"notes": "Live gate RAN on the docker-dev rig with explicit user go-ahead. 8/8 checks pass (check 4 with a documented limitation). Caught + fixed FOUR real defects offline tests missed (4b2f0e6e NU1101 packageSourceMapping, ce9fa07f ASPNETCORE_HTTP_PORTS re-bind, 9137cb41 empty-address-space-on-cache-boot, c6a9f93a cache-not-repopulated-on-RestoreApplied), each with a regression test. Two documented limitations (follow-ups): no replication back-fill of a fully-wiped node; oplog growth on default-OFF nodes. Post-fix: full build 0 errors, Runtime.Tests 409/0, all LocalDb Host.IntegrationTests green; only consistent failure is the infra-gated AbCip_Green_AgainstSim (sim not up). Evidence: 2026-07-20-localdb-phase1-live-gate.md. NOT merged."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-20T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
# OtOpcUa LocalDb Adoption — Phase 2 Implementation Plan (alarm-historian store-and-forward)
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
>
|
||||
> **Execution model:** Optimized for **Claude Opus** agents (`claude --model opus`); dispatch
|
||||
> `high-risk` tasks on Opus. Branch **`feat/localdb-phase2`** in `~/Desktop/OtOpcUa` (remote
|
||||
> `lmxopcua`). **Prerequisite: Phase 1 (`docs/plans/2026-07-20-localdb-adoption-phase1.md`) is
|
||||
> merged (or this branch is stacked on it) and its live gate passed.** Do not merge as part of this
|
||||
> plan; stop at the DoD task.
|
||||
>
|
||||
> **Design authority:** `~/Desktop/scadaproj/docs/plans/2026-07-20-otopcua-localdb-design.md` §3.4,
|
||||
> D8, D9. Reference implementation for "replace a bespoke store" phasing: ScadaBridge Phase 2
|
||||
> (`~/Desktop/ScadaBridge/docs/plans/2026-07-19-localdb-adoption-phase2.md` + its live gate doc).
|
||||
|
||||
**Goal:** Move the alarm-historian store-and-forward buffer (today the standalone
|
||||
`alarm-historian.db` owned by `SqliteStoreAndForwardSink`) into the consolidated LocalDb file as a
|
||||
replicated table with a primary-gated drain, so a redundant pair no longer loses buffered alarm
|
||||
history when a node dies — and delete the sink's bespoke file/connection management outright.
|
||||
|
||||
**Architecture:** `alarm_sf_events` (TEXT GUID PK) registered in `LocalDbSetup.OnReady`; the sink
|
||||
rewired onto `ILocalDb` behind its unchanged public seam; drain gated on the delivered-snapshot
|
||||
Primary role via `PrimaryGatePolicy` (at-least-once across failover, accepted and documented); a
|
||||
one-time idempotent migrator from the legacy file, running **after** registration.
|
||||
|
||||
**Risk framing (from ScadaBridge):** Phase 2 replaces a *working* mechanism — a harder risk class
|
||||
than Phase 1's "add where none existed." Cutover (delete + rewire) lands in **one commit**; there is
|
||||
no dual-mechanism period, and the cutover is the test.
|
||||
|
||||
**Hard rules:** identical to Phase 1's list (OnReady ordering, no autoincrement/BLOB, fail-closed
|
||||
auth already in place, no in-memory SQLite in tests, cp-triplet-only rig inspection), plus:
|
||||
- **Legacy-copy column lists must INTERSECT** with what the legacy file actually has
|
||||
(`pragma_table_info` probe) — a missing column throws and readers silently discard every row.
|
||||
- Absence assertions need a positive control.
|
||||
- DoD greps phrased as "no references from code" (explanatory comments may survive).
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Recon (produces `docs/plans/2026-07-20-localdb-phase2-recon.md`)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/plans/2026-07-20-localdb-phase2-recon.md`
|
||||
- Read-only: `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs` (and the
|
||||
whole `Core.AlarmHistorian` project), `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs`,
|
||||
the drain worker (whatever forwards batches to `GatewayHistorian`/`SendEvent`),
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs`,
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (how `_localRole` +
|
||||
driver-member-count reach the gate today)
|
||||
|
||||
**Record, with `file:line` citations:**
|
||||
1. The sink's exact table schema: name, columns, PK type (**autoincrement?** — decides whether the
|
||||
migrator needs deterministic `mig-{node}-{legacyId}` ids), indices, and any BLOB columns
|
||||
(**STOP condition:** a BLOB payload column cannot be registered — it must become base64 TEXT in
|
||||
the new schema, and the recon must size the largest realistic payload against the 171 KB-ish
|
||||
chunk guidance; alarm events are small JSON, so expect this to be fine, but verify).
|
||||
2. The public seam: the interface the drain worker and producers use (e.g. `IAlarmHistorianSink` /
|
||||
enqueue+dequeue+markDelivered+deadLetter methods), so the rewire can keep it byte-compatible.
|
||||
3. Semantics to preserve: `Capacity` (1,000,000) enforcement, `MaxAttempts` (10), dead-letter
|
||||
retention (30 d), `BatchSize` (100), `DrainIntervalSeconds` (5) — where each lives.
|
||||
4. The drain worker's lifecycle: hosted service or actor? Where a Primary-role check can be
|
||||
injected, and how the delivered-snapshot role (`RedundancyStateChanged` cache) is accessible
|
||||
from it (via `DriverHostActor`, a shared status service, or a message). If the role is only
|
||||
available inside `DriverHostActor`, note the cleanest bridge (e.g. an `IRedundancyRoleView`
|
||||
singleton the actor updates) — that becomes Task 4's shape.
|
||||
5. Whether the sink is constructed per-node config path (`AlarmHistorian:DatabasePath`) anywhere
|
||||
else (tests, tooling).
|
||||
6. How `AlarmHistorian:Enabled=false` short-circuits (NullAlarmHistorianSink) — the rewire must
|
||||
keep the disabled path allocating no LocalDb tables? No: tables are created unconditionally in
|
||||
`OnReady` (cheap, empty); only the sink/drain stay Null. Note this in the doc.
|
||||
|
||||
Commit: `docs(localdb): phase-2 recon findings`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `alarm_sf_events` schema + registration (+ tests)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (Task 2 depends on it)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmSfSchema.cs` (depends only on
|
||||
`Microsoft.Data.Sqlite`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs`
|
||||
- Test: extend `LocalDbSetupTests`
|
||||
|
||||
Schema shape (adjust column names to the recon's findings — preserve today's semantics):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS alarm_sf_events (
|
||||
id TEXT NOT NULL PRIMARY KEY, -- app-minted GUID (never autoincrement)
|
||||
payload_json TEXT NOT NULL,
|
||||
enqueued_at_utc TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | dead
|
||||
last_attempt_utc TEXT NULL,
|
||||
dead_at_utc TEXT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alarm_sf_events_status ON alarm_sf_events(status, enqueued_at_utc);
|
||||
```
|
||||
`OnReady` order becomes: Phase-1 DDL → `AlarmSfSchema.Apply` → the two Phase-1
|
||||
`RegisterReplicated` calls → `RegisterReplicated("alarm_sf_events")` → **migrator (Task 5) last**.
|
||||
(All DDL may run before all registrations; the invariant is registration-before-writes.)
|
||||
|
||||
TDD: failing test first — exact replicated set becomes
|
||||
`["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]` (ordinal-sorted; update the
|
||||
Phase-1 exact-set pins in the same commit — they are *supposed* to go red here, that's them
|
||||
working). Oplog-capture test for an `alarm_sf_events` insert. Commit
|
||||
`feat(localdb): alarm_sf_events replicated table`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewire the sink onto `ILocalDb` + delete bespoke file management (the cutover commit, part 1 of 2 — see Task 3)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs` (or replace
|
||||
with `LocalDbStoreAndForwardSink.cs` — keep the public seam identical either way)
|
||||
- Modify: its registration (`AddAlarmHistorian`) to inject `ILocalDb`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs` — remove
|
||||
`DatabasePath` (a breaking config key removal: note it in the runbook/CHANGELOG task)
|
||||
- Test: the sink's existing unit tests, rewired to a temp-file `ILocalDb` via `TestLocalDb`-style
|
||||
helper (create `tests/.../TestSupport` helper if none exists — real DB, never a stub: a stubbed
|
||||
bare `SqliteConnection` lacks `zb_hlc_next()` and fails closed on registered tables)
|
||||
|
||||
**Steps:**
|
||||
1. Write/port failing tests for the seam's semantics: enqueue, drain batch of `BatchSize`,
|
||||
`MaxAttempts` → dead-letter, capacity enforcement, dead-letter retention purge.
|
||||
2. Implement over `ILocalDb.ExecuteAsync/QueryAsync` (anonymous-object params). Delete the private
|
||||
connection/pragma/file-open code and any `PRAGMA journal_mode` calls (LocalDb owns pragmas).
|
||||
GUIDs minted at enqueue (`Guid.NewGuid().ToString("N")`).
|
||||
3. `Capacity` enforcement: count-based insert guard (preserve today's overflow behavior per recon).
|
||||
4. Core.AlarmHistorian gains a package ref on core `ZB.MOM.WW.LocalDb` (interface only).
|
||||
5. Build + project tests green. **Commit together with Task 3** if the drain gate can't compile
|
||||
separately (the ScadaBridge tasks-14/15/16 circular-dependency landmine — check before assuming
|
||||
they're independent commits).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Primary-gated drain
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:** (exact shape from recon item 4)
|
||||
- Modify: the drain worker
|
||||
- Create (if recon says so): `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs`
|
||||
— a singleton snapshot (`RedundancyRole? LocalRole`, `int DriverMemberCount`) updated by
|
||||
`DriverHostActor` where it already caches `_localRole`
|
||||
- Test: drain-worker tests + an actor test pinning that `DriverHostActor` publishes role changes to
|
||||
the view
|
||||
|
||||
**Semantics:**
|
||||
- Drain runs only when `PrimaryGatePolicy.ShouldServiceAsPrimary(localRole, driverMemberCount)` is
|
||||
true — same policy, same boot-window posture (unknown role drains only when the node is alone).
|
||||
- Delivered/dead-letter marks are row UPDATEs → they replicate, so the standby's copy tracks drain
|
||||
progress and does not re-deliver already-marked rows after failover.
|
||||
- **At-least-once across failover is accepted:** rows delivered on the old primary whose
|
||||
`delivered` mark hadn't replicated yet will be re-sent by the new primary. Document in the
|
||||
runbook (Task 7); do NOT build dedup.
|
||||
- When replication is OFF (default), the gate still applies but `driverMemberCount` for a solo
|
||||
node keeps today's behavior — verify with a test: single-node, role unknown → drains (no
|
||||
regression for unpaired deployments).
|
||||
|
||||
TDD: failing tests — secondary role does not drain; primary drains; unknown+alone drains;
|
||||
unknown+paired does not. Commit (with Task 2 if coupled):
|
||||
`feat(localdb): alarm S&F on LocalDb with primary-gated drain (cutover)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: One-time legacy migrator (`alarm-historian.db` → consolidated)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AlarmSfLegacyMigrator.cs`
|
||||
- Modify: `LocalDbSetup.OnReady` — call it **last**, after all `RegisterReplicated` calls
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/AlarmSfLegacyMigratorTests.cs`
|
||||
|
||||
Mirror `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs`:
|
||||
- Source path from the pre-removal `AlarmHistorian:DatabasePath` default (`alarm-historian.db`) —
|
||||
read the raw config key even though the option property is gone.
|
||||
- `ShouldMigrate`: skip if file missing or `<file>.migrated` exists. `:memory:`/`file:` sources → no-op.
|
||||
- **If the legacy PK is autoincrement (recon):** deterministic ids `mig-{NodeName}-{legacyId}`
|
||||
(node name from the recon-identified config key) — rerunnable without duplicates under
|
||||
`INSERT OR IGNORE`, and no cross-node collision. If already GUIDs, copy as-is.
|
||||
- One transaction for the whole copy; `File.Move(path, path + ".migrated")` only after commit;
|
||||
failure throws out of `OnReady` → boot fails, legacy untouched.
|
||||
- **Column intersection** via `pragma_table_info` on the legacy table; required-PK guard.
|
||||
- Both pair nodes migrate independently; their rows have distinct ids (node-prefixed), so the
|
||||
merged buffer is the union — expected; note that the new primary will drain the standby's
|
||||
migrated rows too.
|
||||
|
||||
TDD: failing tests — happy path row counts; idempotent re-run; failure leaves legacy file
|
||||
untouched; migrated rows **enter the oplog** (assert `__localdb_oplog` — the registration-order
|
||||
pin); older legacy file missing a column still migrates (intersection). Commit
|
||||
`feat(localdb): one-time alarm-historian.db migrator`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Convergence + failover scenarios in the pair harness
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/AlarmSfConvergenceTests.cs`
|
||||
(reuses the Phase-1 `LocalDbPairHarness`)
|
||||
|
||||
**Scenarios:**
|
||||
1. `AlarmBurstOnA_ConvergesToB_AndOplogDrains` — enqueue N events on A; identical rowset on B;
|
||||
oplog → 0.
|
||||
2. `DeliveredMarksReplicate` — mark rows delivered on A; B's copies show delivered (the
|
||||
no-redeliver-after-failover property, asserted at the data layer).
|
||||
3. `WritesWhileTransportDown_SurviveRejoin`.
|
||||
4. Positive control: with `RegisterReplicated("alarm_sf_events")` commented out, scenarios 1–3 go
|
||||
red (run locally, record, restore — do not commit red).
|
||||
|
||||
Commit `test(localdb): alarm S&F convergence scenarios`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rig config + docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-dev/docker-compose.yml` — enable `AlarmHistorian__Enabled=true` on the site-a
|
||||
pair if not already (the gate needs a live buffer); remove any `AlarmHistorian__DatabasePath`
|
||||
env vars (key deleted).
|
||||
- Modify: `docs/operations/2026-07-20-localdb-pair-replication.md` — add: alarm S&F replication
|
||||
semantics, at-least-once-across-failover statement, migrator behavior (`.migrated` sidecar),
|
||||
`DatabasePath` key removal.
|
||||
- Modify: `docs/AlarmHistorian.md` + `CLAUDE.md` — sink now lives in the consolidated LocalDb;
|
||||
drain is primary-gated.
|
||||
|
||||
Commit `docs+chore(localdb): phase-2 rig config + docs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DoD sweep (offline)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
1. Full solution build → 0 warnings; full test suite green (deltas vs pre-branch baseline only).
|
||||
2. Greps, phrased as "no references from **code**": the old bespoke connection management
|
||||
(`AlarmHistorian:DatabasePath`, direct `new SqliteConnection` inside Core.AlarmHistorian except
|
||||
via schema helpers/tests) — explanatory comments may remain.
|
||||
3. Positive-control evidence from Task 5 recorded.
|
||||
4. Exact-set replicated-tables pin = 3 tables, both directions.
|
||||
5. Update `…phase2.md.tasks.json`; commit `chore(localdb): phase-2 DoD sweep`; STOP and report.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Live gate on the docker-dev rig (run only with explicit user go-ahead)
|
||||
|
||||
**Classification:** high-risk (rig)
|
||||
**Estimated implement time:** ~30 min wall-clock
|
||||
**Parallelizable with:** none
|
||||
|
||||
Same inspection rules as Phase 1's gate (cp-triplet, curl sidecar, observer-suspicion rule).
|
||||
Record in `docs/plans/2026-07-20-localdb-phase2-live-gate.md`:
|
||||
1. Migration ran: `.migrated` sidecar present on both site-a nodes; row counts match legacy.
|
||||
2. Alarm burst (drive a real driver alarm or the historian-gateway-unreachable path) converges:
|
||||
identical rowsets, oplog drains to 0, dead letters 0.
|
||||
3. Only the primary drains: stop the historian gateway egress, buffer builds on BOTH nodes'
|
||||
tables (replicated), but only the primary's drain worker logs attempts.
|
||||
4. Failover: stop the primary; the standby (new primary) resumes draining the shared buffer;
|
||||
count of double-delivered events observed and recorded (at-least-once evidence, not a failure).
|
||||
5. Both-nodes-together restart clean; zero `disk I/O error`/`SQLITE_IOERR` in logs.
|
||||
6. site-b (default-OFF pin): sink works locally, no sync traffic.
|
||||
|
||||
Commit the gate doc; report; do not merge.
|
||||
|
||||
---
|
||||
|
||||
## Task persistence
|
||||
|
||||
Tasks file: `docs/plans/2026-07-20-localdb-adoption-phase2.md.tasks.json`. Record deviations per
|
||||
task — especially if Tasks 2/3 had to land as one commit (the expected outcome).
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-20-localdb-adoption-phase2.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 0: Recon — sink schema, seam, drain lifecycle, role-view bridge (STOP conditions)", "status": "pending" },
|
||||
{ "id": 1, "subject": "Task 1: alarm_sf_events schema + registration (+ exact-set pin update)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 2, "subject": "Task 2: Rewire sink onto ILocalDb + delete bespoke file management (cutover 1/2)", "status": "pending", "blockedBy": [1] },
|
||||
{ "id": 3, "subject": "Task 3: Primary-gated drain via PrimaryGatePolicy (cutover 2/2 — may co-commit with Task 2)", "status": "pending", "blockedBy": [2] },
|
||||
{ "id": 4, "subject": "Task 4: One-time alarm-historian.db legacy migrator", "status": "pending", "blockedBy": [3] },
|
||||
{ "id": 5, "subject": "Task 5: Convergence + failover scenarios in the pair harness (+ positive control)", "status": "pending", "blockedBy": [3] },
|
||||
{ "id": 6, "subject": "Task 6: Rig config + docs", "status": "pending", "blockedBy": [3] },
|
||||
{ "id": 7, "subject": "Task 7: DoD sweep (offline) — STOP and report after this", "status": "pending", "blockedBy": [4, 5, 6] },
|
||||
{ "id": 8, "subject": "Task 8: Live gate on the docker-dev rig (needs explicit user go-ahead)", "status": "pending", "blockedBy": [7] }
|
||||
],
|
||||
"lastUpdated": "2026-07-20T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# LocalDb Phase 1 — live gate (docker-dev rig)
|
||||
|
||||
> Task 16 of `2026-07-20-localdb-adoption-phase1.md`. Evidence log for the 8 live-rig checks.
|
||||
> Rig: local `docker-dev/docker-compose.yml` (6 host nodes, one shared SQL). site-a pair replicates;
|
||||
> central + site-b are default-OFF.
|
||||
>
|
||||
> **Safety rules followed:** all DB inspection via `docker cp` of the `db`/`-wal`/`-shm` triplet out
|
||||
> of the container, querying the copy — never host `sqlite3` on the live WAL file. Metrics via a
|
||||
> `curlimages/curl` sidecar with `--network container:<node>` (`aspnet:10.0` has no curl).
|
||||
|
||||
## Rig build/up
|
||||
|
||||
Two real defects surfaced by the rebuild (both fixed on-branch, re-verified):
|
||||
|
||||
1. **`NU1101` — packageSourceMapping gap** (`4b2f0e6e`). `ZB.MOM.WW.LocalDb*` was not mapped to the
|
||||
`dohertj2-gitea` feed, so any *clean* restore (Docker image build, CI, fresh clone) failed. Local
|
||||
dev builds masked it via the warm global NuGet cache. Fixed by adding the two patterns; verified
|
||||
with a temp-packages-dir restore.
|
||||
2. **`ASPNETCORE_HTTP_PORTS` re-bind gap** (`ce9fa07f`). The Kestrel re-bind read only
|
||||
`urls`/`ASPNETCORE_URLS`; the aspnet:8.0+ base image sets `ASPNETCORE_HTTP_PORTS=8080` (all
|
||||
interfaces) as the container default instead. A driver node that never sets `URLS` fell through to
|
||||
`localhost:5000`, silently moving its health/metrics surface to loopback. Central nodes were fine
|
||||
(they set `ASPNETCORE_URLS=9000` explicitly). Fixed to fall through URLS → HTTP_PORTS/HTTPS_PORTS →
|
||||
5000; 4 new unit tests. After the fix, driver nodes bind `[::]:8080` + `[::]:9001` (site-a) /
|
||||
`[::]:8080` only (site-b).
|
||||
|
||||
Rig: 6 nodes rebuilt from branch `feat/localdb-phase1` @ `ce9fa07f`, all up.
|
||||
|
||||
## Checks
|
||||
|
||||
| # | Check | Result | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Rig up, site-a pair healthy, `/metrics` shows `localdb_` series | ✅ PASS | Both site-a nodes `/healthz` 200 Healthy; `/metrics` shows `localdb_oplog_depth` + `localdb_sync_reconnects_total` on meter `ZB.MOM.WW.LocalDb.Replication` (allowlist works); site-a-2 served `/localdb_sync.v1.LocalDbSync/Sync` with routing `match_status=success` (interceptor passed, session established); both oplogs depth 0 |
|
||||
| 2 | Deploy → both site-a nodes' pointer + artifacts byte-identical, same HLC + origin | ✅ PASS | Deploy `6e687451…` (rev `efb04c79…`) via `POST :9200/api/deployments` → both site-a-1 and site-a-2 `deployment_pointer` = `SITE-A / 6e687451… / efb04c79… / sha EFB04C79…` (identical); artifact 1 chunk each; **pointer `__localdb_row_version` identical on both: hlc `116955620225646592`, origin node_id `a0576df6-…`, tombstone 0** — one row replicated, not two derivations; both oplogs depth 0 |
|
||||
| 3 | Boot-from-cache: SQL down, restart site-a-2, serves cached config w/ signal | ✅ PASS (found + fixed a defect) | With SQL stopped, site-a-2 restart logged `RUNNING FROM CACHE — … booted deployment 6e687451…`; **address space materialised from the cached blob**: `AddressSpaceApplier: applied plan (added=18)` / `OpcUaPublish: applied rebuild (added=18)`. Browse confirms **16 nodes on site-a-2 = 16 on site-a-1**. **Defect the gate caught (`9137cb41`):** pre-fix the rebuild re-read the artifact from the down ConfigDb and no-op'd, so the cache-booted node served **1** node vs the healthy peer's 16 — it logged success but browsed empty. Fixed by passing the in-hand cached blob to `RebuildAddressSpace`; regression test added |
|
||||
| 4 | Replicated-cache payoff: wipe a-2 DB, repopulate, then SQL-down boot | ⚠️ PARTIAL — 1 defect fixed; the limitation is since **CLOSED** (LocalDb 0.1.2 — see Summary) | Wiped a-2's LocalDb volume, restarted with **SQL down**: sync session re-established (a-2 served a `Sync` call) but a-2's cache stayed **0/0/0** and a-1's oplog was **0** — the deploy's rows had been acked by the old a-2 and pruned, so there was no delta to send and **no snapshot-resync fired**. ⇒ **Limitation: pure replication does not back-fill a fully-wiped, already-converged node** (library delta-replication; snapshot-resync is gated on the oplog cap, which the running engine never hits). Separately found a **defect (`c6a9f93a`)**: recovery via `RestoreApplied` never wrote the cache (only fresh `ApplyAndAck` did), so a wiped node stayed cache-less. Fixed: `RestoreApplied` now re-caches. Verified — after the fix, wiped a-2 restarted with SQL up: `restored served state … on bootstrap` → cache repopulated `SITE-A / 6e687451`, chunks=1. **Net: a wiped node self-heals its cache from central on next boot (regaining boot-from-cache for future outages); it is not back-filled by its peer during the same outage.** |
|
||||
| 5 | Transport kill: pause a-2, oplog rises; unpause, drains to 0 | ✅ PASS | `docker pause` on a-2 severed the sync transport; during the window a-2 accumulated **oplog=2** unacked local writes (its re-cache) while a-1 held 0. `docker unpause` → a-2's oplog **drained to 0 within 5s**, both converged on the same pointer. Note: the rig's redeploys carry an identical revision, so a fresh cache write on a-1 short-circuits (no-op) — the store-during-partition variant is covered by the automated harness scenario `WritesWhileTransportDown_SurviveRejoin` (proven red without `RegisterReplicated`) |
|
||||
| 6 | Retention: 3rd deploy prunes oldest; chunks gone + tombstones on BOTH nodes | ✅ PASS | Cached 3 distinct deployment-ids (via deploy + restart re-cache). Both nodes retain exactly the newest 2 (`475df87a`, `962962dc`); the oldest `6e687451`'s chunks are **0 on both** (pruned); **1 artifact tombstone on both** — the prune replicated as a tombstone, not left as a live chunk |
|
||||
| 7 | Both-nodes-together restart: clean rejoin, identical counts, no I/O errors | ✅ PASS | Restarted site-a-1 + site-a-2 together: **0** `disk I/O error`/`SQLITE_IOERR`/`corrupt` lines in either log; both re-cached and converged to **identical** counts (ptr `962962dc…`, chunks=2, rowver=3). Byte-identical convergence preserved across a simultaneous restart |
|
||||
| 8 | site-b default-OFF pin: cache works locally, Healthy, no sync listener on 9001 | ✅ PASS (+1 finding, since **CLOSED** by the skip-if-unchanged cache write — see Summary) | site-b-1 cache works locally (`SITE-B / 6e687451`, chunks=1, from its own apply); listeners **`[::]:8080` only — no 9001**; port 9001 **not bound** (`/proc/net/tcp`); `/healthz` **200**; no sync/reconnect metric series. **Finding (follow-up, not a blocker):** `localdb_oplog_depth=5` — because `OnReady` registers replication unconditionally, a default-OFF node captures cache writes into its oplog but has no peer to ack/prune them, so the oplog grows slowly (≈2 rows/deploy, amplified by the re-cache-on-restore fix). Bounded by the library `MaxOplogRows` cap (1M); worth a periodic pruner or skip-if-unchanged in a follow-up |
|
||||
|
||||
## Summary
|
||||
|
||||
**8/8 checks pass** (check 4 with a documented limitation). The gate exercised the real image built
|
||||
from the branch, the real interceptor over a real loopback+cross-container h2c transport, and real
|
||||
`docker cp`-triplet DB inspection — and caught **four real defects that every offline test had
|
||||
missed**, each now fixed on-branch with a regression test:
|
||||
|
||||
1. **`NU1101` packageSourceMapping gap** (`4b2f0e6e`) — `ZB.MOM.WW.LocalDb*` unmapped to the gitea
|
||||
feed; every clean restore (Docker, CI, fresh clone) failed. Masked locally by the warm NuGet cache.
|
||||
2. **`ASPNETCORE_HTTP_PORTS` re-bind gap** (`ce9fa07f`) — the sync-listener re-bind ignored the
|
||||
aspnet:8.0+ container-default port var, silently moving driver health/metrics to loopback:5000.
|
||||
3. **Empty address space on boot-from-cache** (`9137cb41`) — the rebuild re-read the artifact from the
|
||||
down ConfigDb and no-op'd, so a cache-booted node logged success but served clients **1 node vs the
|
||||
healthy peer's 16**. The single most important find: boot-from-cache was half-working.
|
||||
4. **Cache not repopulated on `RestoreApplied`** (`c6a9f93a`) — a wiped/fresh-volume node recovered its
|
||||
served state from central but stayed cache-less, unable to boot-from-cache on the next outage.
|
||||
|
||||
**Two documented limitations (follow-ups, not blockers) — both since CLOSED:**
|
||||
|
||||
- **No replication back-fill of a fully-wiped node** (check 4): a peer's already-acked cache rows are
|
||||
pruned from its oplog and snapshot-resync is gated on the (never-hit) oplog cap, so a wiped node is
|
||||
not healed by its peer — it self-heals from central on next boot instead.
|
||||
→ **FIXED in `ZB.MOM.WW.LocalDb` 0.1.2 + 0.1.3** (scadaproj `cad3bcb` + `3b7489a`), live-gated in
|
||||
`2026-07-21-localdb-followups-live-gate.md`. The diagnosis in this doc was
|
||||
slightly off: the cap was not the gate. `ComputeSnapshotRequiredAsync` measured the peer's gap
|
||||
against the *oldest surviving oplog row* and read an empty oplog as "no gap possible" — and an
|
||||
empty oplog is the **steady state of a converged pair**, since ack-pruning deletes everything the
|
||||
peer has confirmed. So the healthy state was the one state that could not heal a wiped peer. With
|
||||
an empty oplog the gap is now measured against `last_acked_seq`. Covered at both levels:
|
||||
`SnapshotResyncTests.WipedPeer_AfterEverythingWasAckedAndPruned_StillGetsSnapshot` in the library,
|
||||
and `LocalDbPairConvergenceTests.WipedNode_IsBackFilledByItsPeer_WithoutAnyNewDeploy` here —
|
||||
the latter verified RED against the pinned 0.1.1 and green on 0.1.2. **0.1.3 closes a second,
|
||||
deeper half that only became reachable once back-fill worked:** the rebuilt node's OWN writes were
|
||||
silently dropped, because `last_applied_remote_seq` is a watermark in the *peer's* seq space and a
|
||||
rebuilt peer numbers from 1 again. The follow-up live gate caught that one — offline tests could
|
||||
not have, since the scenario had no test while it was still a documented limitation.
|
||||
- **Oplog growth on default-OFF nodes** (check 8): `OnReady` registers replication unconditionally, so
|
||||
a node with no peer accumulates unacked/unpruned oplog rows (~2/deploy). Slow, bounded by the 1M cap;
|
||||
a periodic pruner or a skip-if-unchanged cache write would close it.
|
||||
→ **FIXED at the source**: `LocalDbDeploymentArtifactCache.StoreAsync` now **skips entirely** when
|
||||
the pointer already names this deployment/revision, the SHA matches the bytes, and the expected
|
||||
chunk count is present — so re-caching an artifact the node already holds (every restart's
|
||||
boot-from-cache, every `RestoreApplied`) writes nothing and mints no oplog rows. Two guard tests
|
||||
pin what the skip must NOT do (stale bytes under an unchanged revision; a matching pointer over
|
||||
missing chunks), both verified RED against a naive pointer-only skip.
|
||||
Also correcting this row's bound: the growth was never headed for the 1M cap. `AddZbLocalDbReplication`
|
||||
is registered unconditionally, so `MaintenanceBackgroundService` runs on default-OFF nodes too and
|
||||
the **7-day `MaxOplogAge`** cap prunes — the true bound was ~7 days of writes, not 1M rows.
|
||||
|
||||
Merged to `master` as `c957db52` (2026-07-21) and pushed. Both follow-ups closed on
|
||||
`fix/localdb-phase1-followups` (2026-07-21) and live-gated — see
|
||||
`2026-07-21-localdb-followups-live-gate.md`, which found a third defect in the process.
|
||||
@@ -0,0 +1,72 @@
|
||||
# LocalDb Phase 1 — Execution Progress (resume state)
|
||||
|
||||
**Branch:** `feat/localdb-phase1` (off `master`). Working tree CLEAN, all work committed, nothing pushed.
|
||||
**Plan:** `docs/plans/2026-07-20-localdb-adoption-phase1.md`
|
||||
**Recon:** `docs/plans/2026-07-20-localdb-phase1-recon.md` (READ THIS — it has the 8 deviations D-1..D-8 that override the plan text).
|
||||
**Skill in use:** `superpowers-extended-cc:executing-plans` — batch, verify, commit per task, report between batches.
|
||||
**Do NOT merge to master** — plan stops at Task 15 (DoD) and reports; Task 16 is a rig live-gate needing explicit user go-ahead.
|
||||
|
||||
## Status: Tasks 0–9 DONE (10 commits). Tasks 10–16 REMAIN.
|
||||
|
||||
| # | Task | State | Commit |
|
||||
|---|---|---|---|
|
||||
| 0 | Preflight + recon | ✅ | `42f85507` |
|
||||
| 1 | Package refs (LocalDb 0.1.1, Grpc.AspNetCore 2.76.0) | ✅ | `44255fcb` |
|
||||
| 2 | Schema + LocalDbSetup.OnReady + tests | ✅ | `3b65c24b` |
|
||||
| 3 | Fail-closed sync auth interceptor + tests | ✅ | `3d29be83` |
|
||||
| 4 | LocalDbRegistration + Program.cs DI + appsettings | ✅ | `6aff9a83` |
|
||||
| 5 | h2c sync listener + MapZbLocalDbSync (Kestrel) | ✅ | `b9ddf20e` |
|
||||
| (fix) | rename Runtime.Deployment → Runtime.DeploymentCache | ✅ | `e771a11a` |
|
||||
| 6 | IDeploymentArtifactCache + chunked impl + tests | ✅ | `a38a52b8` |
|
||||
| 9 | Delete LiteDB LocalCache | ✅ | `2bae1b4c` |
|
||||
| 7 | Cache write path in DriverHostActor | ✅ | `1becf591` |
|
||||
| 8 | Boot-from-cache + RunningFromCache signal | ✅ | `a27eff32` |
|
||||
| 10 | Health check + telemetry meter allowlist | ⬜ TODO | |
|
||||
| 11 | DI-pin integration tests over real host graph | ⬜ TODO | |
|
||||
| 12 | 2-node convergence harness + scenarios | ⬜ TODO | |
|
||||
| 13 | docker-dev rig config | ⬜ TODO | |
|
||||
| 14 | Documentation | ⬜ TODO | |
|
||||
| 15 | DoD sweep (offline) — STOP + report | ⬜ TODO | |
|
||||
| 16 | Live gate on docker-dev rig — NEEDS USER GO-AHEAD | ⬜ TODO | |
|
||||
|
||||
Native task list (TaskCreate ids 1–17 map to plan tasks 0–16) tracks the same; tasks.json in plan dir mirrors it.
|
||||
|
||||
## What exists now (key files)
|
||||
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/DeploymentCacheSchema.cs` — DDL, namespace `Runtime.DeploymentCache`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs` — `StoreAsync` / `GetCurrentAsync` / **`GetCurrentUnkeyedAsync`** (D-1 addition) + `CachedDeploymentArtifact` record.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs` — chunked impl. NOTE the prune guard `AND deployment_id <> @DeploymentId` (bug I found: pointer-target could be pruned under same-tick GUIDs → permanent silent miss).
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs` — `OnReady` (DDL→RegisterReplicated). PUBLIC (Host has no InternalsVisibleTo).
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs` — `AddOtOpcUaLocalDb`, `SyncListenPort(config)`. Registers `IDeploymentArtifactCache → LocalDbDeploymentArtifactCache` singleton.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs` — fail-closed bearer.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs` — URL parse/re-bind helper (D-6).
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` — LocalDb wired in `hasDriver` branch (~L176 after AddAlarmHistorian: `AddOtOpcUaLocalDb` + `AddGrpc(interceptor)`); Kestrel re-bind block before `builder.Build()`; `MapZbLocalDbSync()` gated `hasDriver && syncListenPort>0` before `MapOtOpcUaHealth()`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` — `LocalDb` section (Path, SyncListenPort:0, Replication{}), between AlarmHistorian and Deployment.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — cache dep threaded LAST in Props/ctor/forwarding; `CacheAppliedArtifact` (write, after PushDesiredSubscriptions, empty-blob skip, own try/catch); `TryBootFromCache` + `ApplyCachedArtifact` (in Bootstrap catch); `PushDesiredSubscriptionsFromArtifact` split out; `_isRunningFromCache` field; `SingleClusterCacheKey="__single"`; `ReconcileDrivers` now returns `byte[]?`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` — resolves `IDeploymentArtifactCache` (optional, nullable) and passes into DriverHostActor.Props.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/NodeDiagnosticsSnapshot.cs` — added `bool RunningFromCache = false`.
|
||||
- Tests (all green): `Host.IntegrationTests/LocalDbSetupTests.cs`, `LocalDbSyncAuthInterceptorTests.cs`, `LocalDbSyncListenerTests.cs`; `Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs`, `Runtime.Tests/Drivers/DriverHostActorArtifactCacheTests.cs`, `DriverHostActorBootFromCacheTests.cs`.
|
||||
|
||||
## Load-bearing facts for the remaining tasks
|
||||
|
||||
- **Namespace trap (recurs!):** any `Deployment` child namespace under `Runtime` OR `Runtime.Tests` shadows the `Deployment` EF entity → CS0118 across the assembly. Use `...DeploymentCache`. Only a FULL-SOLUTION build catches it (per-project builds pass).
|
||||
- **xUnit1051 is an error** in these test projects: every `ILocalDb`/DB call in a test must pass `TestContext.Current.CancellationToken` (xunit.v3 Host.IntegrationTests) — but `Runtime.Tests` is **xunit v2** (no `TestContext.Current`).
|
||||
- **Test project reality (D-5):** NO `Host.Tests` project; NO `WebApplicationFactory<Program>` (deliberately — `TwoNodeClusterHarness.cs:48` explains why). Host unit tests live in `Host.IntegrationTests` (xunit.v3). Actor tests in `Runtime.Tests` (xunit v2 + Akka.TestKit.Xunit2).
|
||||
- **Task 10 (D-7):** `AddOtOpcUaHealth()` takes NO args, called unconditionally at `Program.cs:365`. DON'T change its signature — register the LocalDb check unconditionally and return `Healthy` when LocalDb absent (ActiveNodeHealthCheck precedent). Health check classes: there are ZERO in-repo today; `Host/Health/` has only `HealthEndpoints.cs`. Meter allowlist is REAL and REQUIRED: `Host/Observability/ObservabilityExtensions.cs:30` `o.Meters = [OtOpcUaTelemetry.MeterName]` — add `LocalDbMetrics.MeterName` (from `ZB.MOM.WW.LocalDb.Replication`). Health check reads `ISyncStatus` + `IOptions<ReplicationOptions>`.
|
||||
- **Task 11:** copy the `TwoNodeClusterHarness` direct-host-build pattern, NOT WebApplicationFactory. Pins: `ILocalDb` singleton + `ReplicatedTables` == `["deployment_artifacts","deployment_pointer"]`; `IDeploymentArtifactCache`→concrete; `ISyncStatus` default-OFF (`Connected==false`); admin-only graph has NO `ILocalDb` + doesn't demand `LocalDb:Path`; health check registered. Set `OTOPCUA_ROLES` per case; `LocalDb:Path`→temp file, `ClearAllPools`+delete triplet in cleanup.
|
||||
- **Task 12:** harness copied from `~/Desktop/ScadaBridge/tests/.../LocalDbSitePairHarness.cs` + `~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/tests/.../Convergence/ConvergenceFixture.cs`. Both nodes init via PRODUCTION `LocalDbSetup.OnReady`. Real loopback Kestrel h2c through the REAL interceptor. DBs as pre-constructed singletons. Positive control: comment out both `RegisterReplicated`, confirm scenarios 1–3 red, restore (don't commit red).
|
||||
- **Task 13 (D-8):** NO host node has a writable volume today — must ADD named volumes. `docker-dev/docker-compose.yml` services: `central-1/2` (admin,driver, MAIN), `site-a-1/2` (driver, SITE-A), `site-b-1/2` (driver, SITE-B). Env style `Section__Key`. `9001` free container-internal. Enable replication on **site-a pair only** (a-1 initiator w/ PeerAddress, a-2 passive, byte-identical ApiKey, MaxBatchSize=16). site-b stays OFF = default-OFF pin. Validate `docker compose config`; DON'T bring rig up.
|
||||
- **Task 15:** baseline pre-existing test failures FIRST (`git stash` → run full `dotnet test` → unstash), report deltas only. **KNOWN intermittent:** one `Runtime.Tests` failure seen ~3× across runs (mine + subagent's), never reproduced under trx logger, NOT one of the new tests — investigate/baseline here.
|
||||
- **Warnings-as-errors** across src + most test projects. Pre-existing CS0618/xUnit1051 warnings exist ONLY in `Client.Shared*` (which don't set TreatWarningsAsErrors) — not mine, ignore.
|
||||
- **SQLitePCLRaw:** no pin needed — LocalDb 0.1.1 nuspec floors `lib.e_sqlite3` at 2.1.12 (CVE-fixed). Confirmed.
|
||||
|
||||
## Follow-ups logged (NOT in scope, don't action without asking)
|
||||
|
||||
- `Polly.Core` in `Configuration.csproj` now orphaned (ResilientConfigReader was its only real consumer). Left in place — recorded in recon §7.3b.
|
||||
- Pre-existing: `TryRecoverFromStale` marks Applied w/o reconciling drivers (recon §3.5); `Install-Services.ps1` gives driver-only nodes no `ASPNETCORE_URLS` (recon §6.4, worked around by D-6).
|
||||
|
||||
## Verify-anything-quick
|
||||
|
||||
Full build: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (expect 0 errors). LocalDb tests:
|
||||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LocalDb"` and
|
||||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ArtifactCache|FullyQualifiedName~BootFromCache"`.
|
||||
@@ -0,0 +1,617 @@
|
||||
# LocalDb Phase 1 — Task 0 Recon Findings
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Branch:** `feat/localdb-phase1` (based on `master`)
|
||||
**Plan:** `docs/plans/2026-07-20-localdb-adoption-phase1.md`
|
||||
**Design authority:** `~/Desktop/scadaproj/docs/plans/2026-07-20-otopcua-localdb-design.md`
|
||||
|
||||
All `file:line` citations are against the branch base commit.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Feed verification: PASS
|
||||
|
||||
```
|
||||
$ dotnet package search ZB.MOM.WW.LocalDb --source dohertj2-gitea --format json
|
||||
ZB.MOM.WW.LocalDb 0.1.1
|
||||
ZB.MOM.WW.LocalDb.Contracts 0.1.1
|
||||
ZB.MOM.WW.LocalDb.Replication 0.1.1
|
||||
```
|
||||
|
||||
All three present at **0.1.1**. No STOP. (Note: the local NuGet cache holds both `0.1.0` and `0.1.1`
|
||||
— pin exactly `0.1.1` so a stale cache cannot win.)
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — STOP conditions: NONE TRIGGERED
|
||||
|
||||
| STOP condition | Verdict |
|
||||
|---|---|
|
||||
| Artifact apply path cannot take a post-apply hook without restructuring the actor | **Clear** — `ApplyAndAck` has a clean insertion point (§2.2) |
|
||||
| `DeploymentId` / cluster identity are not stable strings/GUIDs | **Clear** — both are stable strings, but cluster identity is **not resolvable at boot**; see D-1 |
|
||||
| LiteDB LocalCache is referenced by live code | **Clear** — zero live-code references (§7) |
|
||||
|
||||
Three STOP conditions clear. **Eight deviations** from the plan's stated assumptions are recorded in
|
||||
§9 — four of them (D-1, D-3, D-5, D-6) change the work materially.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deploy fetch path
|
||||
|
||||
### 1.1 Blob-load sites — there are **five** `CreateDbContext()` calls, not two
|
||||
|
||||
| Line | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `DriverHostActor.cs:513` | `Bootstrap()` | `NodeDeploymentStates` + `Deployments` at PreStart |
|
||||
| `DriverHostActor.cs:1472` | `ReconcileDrivers(DeploymentId)` | **loads `ArtifactBlob`** |
|
||||
| `DriverHostActor.cs:1543` | `PushDesiredSubscriptions(DeploymentId)` | **loads `ArtifactBlob` again** |
|
||||
| `DriverHostActor.cs:2000` | `TryRecoverFromStale()` | latest `Sealed` deployment (id + hash only, no blob) |
|
||||
| `DriverHostActor.cs:2029` | `UpsertNodeDeploymentState(...)` | writes `NodeDeploymentState` |
|
||||
|
||||
`DriverHostActor.cs:1472-1476`:
|
||||
```csharp
|
||||
using var db = _dbFactory.CreateDbContext();
|
||||
blob = db.Deployments.AsNoTracking()
|
||||
.Where(d => d.DeploymentId == deploymentId.Value)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
```
|
||||
`:1543-1547` is byte-identical apart from the local. **The same blob is read twice per apply.**
|
||||
|
||||
### 1.2 Artifact runtime type — there is no instance type
|
||||
|
||||
`DeploymentArtifact` (`DeploymentArtifact.cs:48`) is a **static parser class** over
|
||||
`ReadOnlySpan<byte>`. The blob is never materialised into an object graph.
|
||||
|
||||
- Storage: `byte[]` — `Deployment.cs:29` `public byte[] ArtifactBlob { get; init; } = Array.Empty<byte>();`
|
||||
- Format: **UTF-8 JSON**, `JsonDocument.Parse` (`DeploymentArtifact.cs:66`, `:490`). Not MessagePack.
|
||||
- Leniency contract: empty/malformed blob → empty result, **never throws** (`:62`, `:537-541`).
|
||||
|
||||
**Consequence for the cache:** we cache raw `byte[]`. No serializer coupling. Good.
|
||||
|
||||
### 1.3 Identity types
|
||||
|
||||
| Type | Definition | Shape |
|
||||
|---|---|---|
|
||||
| `DeploymentId` | `Commons/Types/DeploymentId.cs:3` — `readonly record struct DeploymentId(Guid Value)` | `ToString()` → `Value.ToString("N")`, 32 hex chars, **no hyphens** (`:10`) |
|
||||
| `RevisionHash` | `Commons/Types/RevisionHash.cs:8` — `readonly record struct RevisionHash(string Value)` | SHA-256 hex, **lowercase 64 chars**, no `0x`; empty invalid (`:3-6`) |
|
||||
|
||||
Both are stable, filesystem/SQL-safe strings. Use `DeploymentId.ToString()` (the "N" form) as the
|
||||
`deployment_id` TEXT column value.
|
||||
|
||||
---
|
||||
|
||||
## 2. Post-apply point
|
||||
|
||||
### 2.1 `ApplyAndAck` control flow — `DriverHostActor.cs:1413-1458`
|
||||
|
||||
```
|
||||
1415 _applyingDeploymentId = deploymentId;
|
||||
1416 Become(Applying);
|
||||
1425 UpsertNodeDeploymentState(..., Applying, null)
|
||||
1427 try {
|
||||
1429 ReconcileDrivers(deploymentId); // blob fetch #1 — SWALLOWS DB errors
|
||||
1430 _currentRevision = revision;
|
||||
1431 UpsertNodeDeploymentState(..., Applied, null);
|
||||
1432 SendAck(..., ApplyAckOutcome.Applied, null, correlation);
|
||||
1436 _opcUaPublishActor?.Tell(RebuildAddressSpace(...));
|
||||
1439 PushDesiredSubscriptions(deploymentId); // blob fetch #2 — SWALLOWS DB errors
|
||||
1440 OtOpcUaTelemetry.DeploymentApplied.Add(1, "outcome"="ack");
|
||||
1441 _log.Info("applied deployment ...");
|
||||
1443 }
|
||||
1444 catch (Exception ex) { ...Failed ACK... }
|
||||
1452 finally { _applyingDeploymentId = null; Become(Steady); }
|
||||
```
|
||||
|
||||
### 2.2 Where the cache write belongs
|
||||
|
||||
**Immediately after `DriverHostActor.cs:1439`**, before `:1440` — but **wrapped in its own
|
||||
try/catch**. Reason: `:1432` already sent an `Applied` ACK to the coordinator. An unguarded cache
|
||||
write that throws would fall into the `catch` at `:1444` and send a *second*, contradictory
|
||||
`Failed` ACK for a deployment the fleet already believes is applied.
|
||||
|
||||
### 2.3 Paths that must NOT write to the cache
|
||||
|
||||
1. **`catch` at `:1444`** — any apply failure.
|
||||
2. **Idempotent short-circuit at `:1402-1409`** — `HandleDispatchFromSteady` returns early with an
|
||||
`Applied` ACK when `_currentRevision == msg.RevisionHash`. `ApplyAndAck` is never entered and no
|
||||
blob is read.
|
||||
3. **⚠ THE SILENT-DEGRADATION TRAP.** `ReconcileDrivers:1478-1483` and
|
||||
`PushDesiredSubscriptions:1549-1553` catch DB failures, log a **warning**, and `return` **without
|
||||
rethrowing**. `ApplyAndAck` therefore reaches `:1440` and declares success having applied **zero
|
||||
drivers** when the blob load failed. Caching "whatever we read" would **persist an empty artifact
|
||||
as a successful apply** — poisoning the boot-from-cache path with a config that starts no drivers.
|
||||
|
||||
**Mitigation (load-bearing):** the cache write must be gated on the blob having actually loaded
|
||||
non-empty. Have `ReconcileDrivers` surface the loaded blob upward (a field or an out-param)
|
||||
rather than re-reading it a third time, and skip the cache write when it is
|
||||
`Array.Empty<byte>()`. This also removes the duplicate read noted in §1.1.
|
||||
|
||||
4. **`RestoreApplied` (`:1514-1528`)** — the bootstrap replay path. Restoring from central; caching
|
||||
here is harmless and arguably desirable, but it is a distinct seam. **Phase 1 scope: skip it.**
|
||||
5. **`TryRecoverFromStale` (`:1996-2022`)** — never reads the artifact at all (see §3.4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Cold-boot path
|
||||
|
||||
### 3.1 PreStart — `DriverHostActor.cs:415-436`
|
||||
|
||||
Ctor already did `Become(Steady)` at `:411`. PreStart subscribes to topics, spawns the virtual-tag
|
||||
and scripted-alarm hosts, then calls `Bootstrap()` at `:435`.
|
||||
|
||||
### 3.2 `Bootstrap()` — `DriverHostActor.cs:506-565` — **yes, it queries central SQL at boot**
|
||||
|
||||
Two queries inside one `try`: latest `NodeDeploymentState` for self (`:514-518`), then the matching
|
||||
`Deployment` row (`:527-532`). Four outcomes: no prior deployments → Steady; `Applied` →
|
||||
`RestoreApplied` (`:544`); `Applying` orphan → `ApplyAndAck` replay (`:549`); `Failed` → Steady.
|
||||
|
||||
### 3.3 **THE SEAM** — `DriverHostActor.cs:560-564`
|
||||
|
||||
```csharp
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: ConfigDb unreachable on bootstrap; entering Stale", _localNode);
|
||||
Become(Stale);
|
||||
}
|
||||
```
|
||||
|
||||
**This is the exact point where "central fetch failed at boot" is known.** The cache fallback goes
|
||||
between `:562` and `:563`: attempt a local-cache load, and only `Become(Stale)` if the cache is also
|
||||
a miss.
|
||||
|
||||
⚠ The catch also fires if the *second* query throws after the first succeeded, so `latest` is not in
|
||||
scope-valid state from the catch. **The cache must be self-describing** — it must carry its own
|
||||
`DeploymentId` + `RevisionHash`, because at this seam the actor has no idea what it should be
|
||||
restoring. The planned `deployment_pointer` shape already satisfies this.
|
||||
|
||||
### 3.4 The `Stale` state — `DriverHostActor.cs:1343-1379`
|
||||
|
||||
`ReconnectInterval = 30s` (`:55`), retry timer started at `:1378`.
|
||||
|
||||
The dispatch-ignore at **`:1345-1348`** discards `DispatchDeployment` entirely — no ACK, no
|
||||
buffering (contrast `Applying:599` which does `Self.Forward(msg)`). A Stale node looks like a
|
||||
timeout to the coordinator. Phase 1 does **not** change this (per design §3.3: a *new* deployment
|
||||
still requires central).
|
||||
|
||||
`RouteNodeWrite` fast-fails at `:1359-1360` with `"driver host stale (config DB unreachable)"`.
|
||||
|
||||
### 3.5 Pre-existing gap worth recording (not Phase 1 scope)
|
||||
|
||||
`TryRecoverFromStale` (`:1996-2022`) sets `_currentRevision` and marks `Applied` **without calling
|
||||
`ReconcileDrivers` or `PushDesiredSubscriptions`** — documented at `:1692-1695` and `:1706`. A
|
||||
Stale-recovered node reports Applied while running **zero drivers**, and
|
||||
`HandleDispatchFromSteady:1402` then short-circuits on revision match. Boot-from-cache largely
|
||||
neutralises this in practice, but the bookkeeping remains optimistic. **Logged as a follow-up, not
|
||||
fixed here.**
|
||||
|
||||
### 3.6 `DbHealthProbeActor`
|
||||
|
||||
**No interaction with `DriverHostActor` whatsoever.** Spawned as a sibling
|
||||
(`ServiceCollectionExtensions.cs:243-246`), consumed only by `OpcUaPublishActor`
|
||||
(`OpcUaPublishActor.cs:105`, `:256`, `:528`, `:543-544`). `DriverHostActor` runs its own independent
|
||||
30s `retry-db` timer. If running-from-cache should later influence OPC UA ServiceLevel,
|
||||
`OpcUaPublishActor`'s DB-health seam is the precedent to mirror.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cluster identity — **the one real design problem (see D-1)**
|
||||
|
||||
**There is no `ClusterId` field on `DriverHostActor` and no accessor for it.** The actor knows only
|
||||
`_localNode` (`DriverHostActor.cs:61`, type `Commons.Types.NodeId`, aliased at `:28`).
|
||||
|
||||
`ClusterId` is resolved **per-artifact**, by `DeploymentArtifact.ResolveClusterScope(blob, nodeId)`
|
||||
(`DeploymentArtifact.cs:485-516`) — it scans the artifact's `Nodes[]` array for a matching `NodeId`
|
||||
and reads that element's `ClusterId`. Types: `ClusterScope` (`:46`), `ClusterFilterMode` (`:28`).
|
||||
|
||||
`IClusterRoleInfo` (`Commons/Interfaces/IClusterRoleInfo.cs:12`) exposes `LocalNode`, `LocalRoles`,
|
||||
`HasRole`, `MembersWithRole`, `RoleLeader`, `RoleLeaderChanged` — **no `ClusterId`**.
|
||||
`docker-dev/docker-compose.yml` has `Cluster__Hostname`/`Port`/`Roles`/`SeedNodes` — **no
|
||||
`Cluster__ClusterId`**. The `ClusterNode` entity
|
||||
(`Configuration/Entities/ClusterNode.cs:7,10`) carries both, but lives in central SQL — exactly
|
||||
what is unreachable at the seam that needs it.
|
||||
|
||||
> **The bind:** the design keys the cache by `ClusterId` so a replicated pair shares one cache
|
||||
> entry. But `ClusterId` is only discoverable *from a blob you already have* or *from central SQL*.
|
||||
> At the §3.3 boot-failure seam we have neither.
|
||||
|
||||
**Resolution adopted (D-1):** keep `cluster_id` as the `deployment_pointer` PK — pair sharing is the
|
||||
whole point of replication and must not be given up. Resolve it at each end as follows:
|
||||
|
||||
- **Write path** (`ApplyAndAck`, §2.2): call `DeploymentArtifact.ResolveClusterScope(blob, nodeId)`
|
||||
on the blob just applied. `ClusterFilterMode.ScopeTo` → use its `ClusterId`. `None` (single-cluster
|
||||
artifact) or `Suppress` → fall back to the literal `"__single"` sentinel, matching the actor's own
|
||||
existing degenerate-case convention at `:1827-1830`.
|
||||
- **Read path** (boot seam, §3.3): we cannot compute the key, so **do not key the read**. Select the
|
||||
single `deployment_pointer` row, ordered by `applied_at_utc DESC`, `LIMIT 1`. A node belongs to
|
||||
exactly one cluster and its pair peer replicates the same cluster's row, so the table holds one
|
||||
row in every real topology. If more than one row is present (a node was re-homed between
|
||||
clusters), take the newest and **log a warning naming both cluster ids** — an operator-visible
|
||||
signal rather than a silent wrong-config boot.
|
||||
- Optional escape hatch: honour a `Cluster:ClusterId` config key when present. **Deferred** — not
|
||||
needed for the rig, and adding a required key contradicts the design's "storage ships
|
||||
unconditionally" posture.
|
||||
|
||||
This preserves the replication payoff (design §3.3) and the schema exactly as specified, and
|
||||
confines the change to the read query's `WHERE` clause.
|
||||
|
||||
---
|
||||
|
||||
## 5. `DriverHostActor` construction — the `Props` expression tree
|
||||
|
||||
### 5.1 The factory — `DriverHostActor.cs:326-346`
|
||||
|
||||
`static Props(...)` takes **16 parameters**, 14 optional, and forwards them **fully positionally**
|
||||
into `Akka.Actor.Props.Create(() => new DriverHostActor(...))` at **`:343-346`**. Ctor at
|
||||
`:375-412`, assignments `:393-408`.
|
||||
|
||||
> ⚠ Six `IActorRef?` parameters and three interface-typed parameters mean many mis-bindings are
|
||||
> **type-compatible and therefore compile clean**. Callers use *named* arguments (safe); the
|
||||
> internal forwarding at `:343-346` is *positional* (unsafe).
|
||||
>
|
||||
> **Rule for Task 7: append the new parameter LAST in the `Props` signature, LAST in the ctor
|
||||
> signature, and LAST in the positional forwarding list at `:346`. Change nothing else.**
|
||||
|
||||
### 5.2 Construction sites — **27 total** (1 production + 26 test)
|
||||
|
||||
**Production (1):** `Runtime/ServiceCollectionExtensions.cs:332`, inside `WithOtOpcUaRuntimeActors`
|
||||
(`:193`); registered as `DriverHostActorKey` (`:344`). All args named except the first three. Does
|
||||
not pass `virtualTagHostOverride`, `scriptedAlarmHostOverride`, or `driverMemberCountProvider`.
|
||||
|
||||
`WithOtOpcUaRuntimeActors` callers: `Host/Program.cs:331`,
|
||||
`Host.IntegrationTests/TwoNodeClusterHarness.cs:398`, `ServiceCollectionExtensionsTests.cs:42,144`.
|
||||
|
||||
**Tests (26)**, all in `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/`:
|
||||
|
||||
| File | Lines |
|
||||
|---|---|
|
||||
| `Drivers/DriverHostActorTests.cs` | 28, 60, 81, 118, 143, 189 |
|
||||
| `Drivers/DriverHostActorReconcileTests.cs` | 37, 59, 90, 119, 156, 193 |
|
||||
| `Drivers/DriverHostActorDiscoveryTests.cs` | 385, 671, 748, 1023 |
|
||||
| `Drivers/DriverHostActorWriteRoutingTests.cs` | 148, 208 |
|
||||
| `Drivers/DriverHostActorPrimaryGateTests.cs` | 228, 247 |
|
||||
| `Drivers/DriverHostActorVirtualTagTests.cs` | 44, 76 |
|
||||
| `Drivers/DriverHostActorNativeAlarmTests.cs` | 350 |
|
||||
| `Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs` | 126 |
|
||||
| `Drivers/DriverHostActorProbeResultDropTests.cs` | 66 |
|
||||
| `Drivers/DriverHostActorHistoryWriterTests.cs` | 59 |
|
||||
| `Drivers/DiscoveryInjectionEndToEndTests.cs` | 219 |
|
||||
| `VirtualTags/DependencyMuxActorTests.cs` | 139 |
|
||||
|
||||
No site calls `Props.Create<DriverHostActor>()` or `new DriverHostActor(...)` directly — the only
|
||||
`new DriverHostActor` in the tree is inside the factory at `:343`. Because the new parameter is
|
||||
optional and appended last, **no test site should need editing.**
|
||||
|
||||
### 5.3 Status object for the running-from-cache signal
|
||||
|
||||
`NodeDiagnosticsSnapshot` (`Commons/Interfaces/NodeDiagnosticsSnapshot.cs:17-22`) — built in
|
||||
`HandleGetDiagnostics` (`DriverHostActor.cs:1381-1398`), a positional record of
|
||||
`(NodeId, RevisionHash? CurrentRevision, IReadOnlyList<DriverInstanceDiagnostics> Drivers, DateTime AsOfUtc)`.
|
||||
`GetDiagnostics` is handled in **all three states** (`Steady:570`, `Applying:601`, `Stale:1349`), so
|
||||
a flag here is observable regardless of state. **This is the natural home for `RunningFromCache`**
|
||||
(append last to the record). Consumer: `IFleetDiagnosticsClient` → AdminUI.
|
||||
|
||||
`IDriverHealthPublisher` (`:73`, defaulted `:402`) is **not** a fit — the host never calls it; it
|
||||
only passes it down to `DriverInstanceActor` children (`:1845`, `:1859`). Per-driver, not per-node.
|
||||
|
||||
### 5.4 Async / side-effect IO convention
|
||||
|
||||
The actor is **overwhelmingly synchronous-blocking on DB IO** — all five `CreateDbContext()` sites
|
||||
use `using var db = ...` with synchronous LINQ on the actor thread. No `async`/`await` in the file,
|
||||
no `Task.Run`.
|
||||
|
||||
The single async pattern is `ContinueWith(..., TaskScheduler.Default).PipeTo(replyTo)` in
|
||||
`HandleRouteNodeWrite` (`:1188-1200`), with an explicit `Sender` capture before the continuation.
|
||||
|
||||
**Recommendation for Task 7:** a **synchronous** cache write at the §2.2 point is *consistent with
|
||||
the file's existing style* and is correct there — the actor is mid-apply, holds `Applying`, and
|
||||
every other DB call on that path already blocks. A fire-and-forget `PipeTo(Self)` would need a
|
||||
handler registered in `Steady`, `Applying` **and** `Stale`, or it dead-letters after the
|
||||
`Become(Steady)` at `:1456`. **Prefer synchronous + try/catch.** (This deviates from the plan's
|
||||
"fire-and-forget"; see D-4.)
|
||||
|
||||
`IWithTimers` is already implemented (`:50`, `:282`); `"retry-db"` key in use at `:1378`/`:2009`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Host composition root
|
||||
|
||||
### 6.1 Role flag — `Program.cs:47-50`
|
||||
|
||||
```csharp
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
var hasAdmin = roles.Contains("admin");
|
||||
var hasDriver = roles.Contains("driver");
|
||||
```
|
||||
Allowed roles: `admin`, `driver`, `dev` (`Cluster/RoleParser.cs:5-8`); unknown throws (`:25-27`).
|
||||
|
||||
### 6.2 `hasDriver` branch — `Program.cs:131-310`
|
||||
|
||||
Anchor for the new call: **`Program.cs:173-175`** `builder.Services.AddAlarmHistorian(...)`, inside
|
||||
the config-gated cluster at `:154-195`. Must land **before** `AddAkka` at `:314`.
|
||||
|
||||
### 6.3 Pipeline — `Program.cs:368-404`
|
||||
|
||||
`:383` `MapStaticAssets` · `:385-396` `if (hasAdmin) { ... }` · **`:398` `MapOtOpcUaHealth()`** ·
|
||||
`:399` `MapOtOpcUaMetrics()` · `:404` `RunAsync()`.
|
||||
|
||||
⚠ **There is no `hasDriver` block in the pipeline half** — only `hasAdmin` is branched on. A
|
||||
driver-gated `app.Map*` is a new construct; it goes between `:396` and `:398`.
|
||||
|
||||
`Program.cs:406-410` re-exports `public partial class Program`.
|
||||
|
||||
### 6.4 Kestrel / URLs — **CONFIRMED: no Kestrel configuration exists**
|
||||
|
||||
- `Program.cs:57` `builder.WebHost.UseStaticWebAssets();` — the **only** `builder.WebHost` call.
|
||||
- **Zero** `ConfigureKestrel` / `UseUrls` / `ListenAnyIP` / `UseKestrel` anywhere in `src/`.
|
||||
- Binding is exclusively via `ASPNETCORE_URLS`: `docker-compose.yml:173` (central-1) and `:240`
|
||||
(central-2) set `http://+:9000`; `launchSettings.json:7` sets `http://localhost:9000`.
|
||||
|
||||
Two traps for Task 5:
|
||||
|
||||
1. ⚠ **`scripts/install/Install-Services.ps1:186`** (`$hostEnv += "ASPNETCORE_URLS=..."`) is inside
|
||||
`if ($hasAdmin)` at `:185`. **A driver-only Windows-service node gets no `ASPNETCORE_URLS` at
|
||||
all** and binds the ASP.NET default. The plan's parse-URLs-then-rebind snippet would re-bind
|
||||
`http://+:9000` there — a port that node never listened on. The `?? "http://+:9000"` fallback in
|
||||
the plan's snippet is therefore **wrong for driver-only nodes**; see D-6.
|
||||
2. ⚠ **`Host.IntegrationTests/TwoNodeClusterHarness.cs:331`** already calls
|
||||
`builder.WebHost.UseKestrel(o => o.Listen(IPAddress.Parse(LoopbackHost), 0));`. Adding a
|
||||
production `ConfigureKestrel` gives two competing explicit configurations in integration tests —
|
||||
last-one-wins. The `syncPort > 0` gate keeps this dormant by default (harness sets no sync port),
|
||||
but any harness that *does* set one will fight the port-0 binding.
|
||||
|
||||
**Health route for smoke tests:** `/healthz` — liveness, runs no checks, always 200 while the
|
||||
process is up, `AllowAnonymous`. (`Health/HealthEndpoints.cs:48` → `MapZbHealth()`; routes from the
|
||||
`ZB.MOM.WW.Health` package.) Also `/health/ready`, `/health/active`, and `/metrics` (`Program.cs:399`).
|
||||
|
||||
### 6.5 `SecretsRegistration` template — `Host/Configuration/SecretsRegistration.cs`
|
||||
|
||||
Shape for `LocalDbRegistration` to mirror: `public static class` in
|
||||
`ZB.MOM.WW.OtOpcUa.Host.Configuration`; `public const string` section/key path constants; a
|
||||
`public static bool IsXEnabled(IConfiguration)` predicate using
|
||||
`GetValue(key, defaultValue: false)` (**default-deny**) that `Program.cs` can also call;
|
||||
`AddOtOpcUaX(this IServiceCollection, IConfiguration)` returning `IServiceCollection`, both args
|
||||
`ArgumentNullException.ThrowIfNull`-guarded; XML docs with `<remarks><para>` explaining *why* the
|
||||
gate is default-deny.
|
||||
|
||||
Call-site precedent: `Program.cs:363` `AddOtOpcUaSecrets(...)`; predicate reused inside the `AddAkka`
|
||||
lambda at `Program.cs:323`.
|
||||
|
||||
### 6.6 Telemetry meter allowlist — **EXISTS. Task 10 step 3 is REQUIRED, not conditional.**
|
||||
|
||||
`Host/Observability/ObservabilityExtensions.cs:25-38`:
|
||||
```csharp
|
||||
return services.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "otopcua";
|
||||
o.Meters = [OtOpcUaTelemetry.MeterName]; // <-- ObservabilityExtensions.cs:30
|
||||
o.ActivitySources = [OtOpcUaTelemetry.ActivitySourceName];
|
||||
```
|
||||
|
||||
`o.Meters` is a **strict allowlist** — `ZbTelemetryExtensions.cs:51-54` iterates it calling
|
||||
`metrics.AddMeter(name)`; **no wildcard support**. Anything unlisted is silently not exported.
|
||||
Single element today: `OtOpcUaTelemetry.MeterName` = `"ZB.MOM.WW.OtOpcUa"`
|
||||
(`Commons/Observability/OtOpcUaTelemetry.cs:19`).
|
||||
|
||||
**Action:** add `LocalDbMetrics.MeterName` at `ObservabilityExtensions.cs:30`. This is exactly the
|
||||
omission the ScadaBridge live gate caught.
|
||||
|
||||
### 6.7 Health registration — `Host/Health/HealthEndpoints.cs:19-41`
|
||||
|
||||
`AddOtOpcUaHealth()` **takes no parameters** and is called **unconditionally** at `Program.cs:365`,
|
||||
outside both role blocks. Three `.AddTypeActivatedCheck<>` calls (`configdb`, `akka`,
|
||||
`admin-leader`); **no registration-time role gating exists** — `ActiveNodeHealthCheck` does runtime
|
||||
role scoping instead (returns `Healthy` when the node lacks the role).
|
||||
|
||||
⚠ **There are zero `IHealthCheck` implementations in this repo** — all three come from the
|
||||
`ZB.MOM.WW.Health.*` packages (`Directory.Packages.props:124-126`, v0.1.0). A LocalDb check would be
|
||||
the **first health-check class in the tree**; `Host/Health/` holds only `HealthEndpoints.cs` today.
|
||||
|
||||
**Consequence for Task 10:** driver-gating the new check requires changing
|
||||
`AddOtOpcUaHealth()`'s signature (add `bool hasDriver` or `IConfiguration`) and its `Program.cs:365`
|
||||
call site. **Preferred alternative:** follow the `ActiveNodeHealthCheck` precedent — register
|
||||
unconditionally and have the check return `Healthy` when LocalDb is not registered. That keeps the
|
||||
signature and matches the "default-OFF must not degrade a plain node" requirement for free. See D-7.
|
||||
|
||||
### 6.8 Config files
|
||||
|
||||
| File | Top-level sections |
|
||||
|---|---|
|
||||
| `appsettings.json` | `Serilog`, `Security`, `Secrets`, `ServerHistorian`, `ContinuousHistorization`, `AlarmHistorian`, `Deployment` |
|
||||
| `appsettings.driver.json` / `.admin.json` / `.admin-driver.json` / `.Development.json` | `Serilog`, `Security` only |
|
||||
|
||||
Overlay selection: `Program.cs:67` sorts roles ordinally and joins with `-` → `admin,driver`
|
||||
becomes `appsettings.admin-driver.json`, added `optional: true` at `:70`; env vars (`:77`) and CLI
|
||||
args (`:78`) are re-appended after, so deployment overrides outrank the overlay.
|
||||
|
||||
**`LocalDb` key search: CONFIRMED ABSENT.** Case-insensitive `localdb` across all `*.json`,
|
||||
`*.yml`, `*.ps1`, `*.csproj`, `*.props` matches only the two Phase-1/Phase-2 planning artifacts. No
|
||||
conflicting keys in any overlay or in `Install-Services.ps1`.
|
||||
|
||||
**Placement convention:** each feature section opens with a `"_comment"` string then `"Enabled":
|
||||
false`; per-field caveats use sibling `"_<Field>Comment"` keys (`appsettings.json:34`, `:45`). Place
|
||||
`LocalDb` between `AlarmHistorian` (ends `:61`) and `Deployment` (`:62`).
|
||||
|
||||
---
|
||||
|
||||
## 7. LiteDB LocalCache — dormant, safe to delete
|
||||
|
||||
### 7.1 Files (6) under `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/`
|
||||
|
||||
`ILocalConfigCache.cs`, `GenerationSnapshot.cs`, `StaleConfigFlag.cs`, `LiteDbConfigCache.cs`
|
||||
(also declares `LocalConfigCacheCorruptException` at `:128`), `GenerationSealedCache.cs`,
|
||||
`ResilientConfigReader.cs`. Single namespace `...Configuration.LocalCache`. The only two
|
||||
`using LiteDB;` statements in `src/` are `LiteDbConfigCache.cs:1` and `GenerationSealedCache.cs:1`.
|
||||
|
||||
### 7.2 Reference classification — **no live-code references. NO STOP.**
|
||||
|
||||
- **(c) live code elsewhere: ZERO.** Also confirms full dormancy: `SealedBootstrap` (referenced by
|
||||
`docs/v2/v2-release-readiness.md:46`) has **zero hits** in `src/` and `tests/` — it no longer exists.
|
||||
- Two benign near-misses:
|
||||
- `Configuration/Services/ILdapGroupRoleMappingService.cs:14` — the string `ResilientConfigReader`
|
||||
inside an **XML doc comment**. Prose only. Needs a comment edit or the doc goes stale.
|
||||
- `Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj:29` — `<PackageReference Include="LiteDB"/>`,
|
||||
the only one in the repo.
|
||||
- Dozens of `LocalConfigCacheCorruptException` hits under `src/**/bin/**/*.xml` are **generated doc
|
||||
build artifacts** (`GenerateDocumentationFile=true`), not source.
|
||||
|
||||
### 7.3 ⚠ Test-file drift — the plan's delete list is incomplete
|
||||
|
||||
Plan `…phase1.md:484-485` names only `GenerationSealedCacheTests.cs` and
|
||||
`ResilientConfigReaderTests.cs`. Actual test files in
|
||||
`tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/`:
|
||||
|
||||
| File | Status |
|
||||
|---|---|
|
||||
| `GenerationSealedCacheTests.cs` | in plan ✅ |
|
||||
| `ResilientConfigReaderTests.cs` | in plan ✅ — note `StaleConfigFlagTests` is co-located here at `:323` |
|
||||
| **`LiteDbConfigCacheTests.cs`** | **MISSING from the plan** — will fail to compile if the source is deleted (`:125` calls `new LiteDB.LiteDatabase(...)` directly) |
|
||||
|
||||
See D-3.
|
||||
|
||||
### 7.3b Outcome (Task 9, executed 2026-07-20)
|
||||
|
||||
Deleted: the 6 `LocalCache/` sources and **all three** test files (including
|
||||
`LiteDbConfigCacheTests.cs`, which the plan omitted — D-3). `LiteDB` removed from both
|
||||
`Configuration.csproj` and `Directory.Packages.props`. The stale XML-doc reference at
|
||||
`ILdapGroupRoleMappingService.cs:14` was rewritten to state the present truth (no fallback exists;
|
||||
reviving it means an admin-side cache on LocalDb, not restoring the old pipeline) rather than
|
||||
deleted, per the DoD's "explanatory prose may remain". Configuration tests: 92/92 green.
|
||||
|
||||
**Follow-up found while executing:** `Polly.Core` in `Configuration.csproj` is now orphaned too —
|
||||
`ResilientConfigReader` was its only real consumer in that project (remaining `Polly` mentions are
|
||||
comments). Left in place deliberately: removing it is outside Task 9's scope and could break a
|
||||
consumer relying on the transitive flow. Worth a separate cleanup.
|
||||
|
||||
### 7.4 Package removal
|
||||
|
||||
- `PackageReference`: `Configuration.csproj:29` — only consumer.
|
||||
- `PackageVersion`: `Directory.Packages.props:35` — `LiteDB` `5.0.21`.
|
||||
|
||||
Both safe to delete; no transitive consumer.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test projects, rig, packages
|
||||
|
||||
### 8.1 Test project layout
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/` | **DOES NOT EXIST** — there is no Host *unit*-test project at all |
|
||||
| Closest Host project | `Host.IntegrationTests` — despite the name it carries plenty of pure unit tests (`LdapOptionsValidatorTests`, `ServerHistorianOptionsValidatorTests`, `ResilienceInvokerFactoryRegistrationTests`, …) |
|
||||
| `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` | **EXISTS** |
|
||||
| DriverHostActor tests | `Runtime.Tests/Drivers/` — 11 `DriverHostActor*Tests.cs` files. **xunit v2** (`xunit` 2.9.3), references **`Akka.TestKit.Xunit2`** ✅ |
|
||||
| `Host.IntegrationTests` xunit version | **xunit.v3** 1.1.0; SDK `Microsoft.NET.Sdk.Web` |
|
||||
|
||||
`Directory.Packages.props` documents that AdminUI.Tests / ControlPlane.Tests / **Runtime.Tests** are
|
||||
the three xunit-v2 holdouts, held there by `Akka.TestKit.Xunit2` (verified 2026-07-13: no xunit.v3
|
||||
Akka TestKit exists). This matches the design's "actor tests live in an xunit2 TestKit project".
|
||||
|
||||
### 8.2 ⚠ `WebApplicationFactory<Program>` is **deliberately not used** — Task 11 must be rewritten
|
||||
|
||||
The only mention of the type in the entire `tests/` tree is a comment explaining why it is avoided:
|
||||
|
||||
`Host.IntegrationTests/TwoNodeClusterHarness.cs:48`
|
||||
```
|
||||
/// <para>Why not <c>WebApplicationFactory<Program></c>? Program.cs reads <c>OTOPCUA_ROLES</c> ...
|
||||
```
|
||||
|
||||
The actual pattern builds the host directly — `TwoNodeClusterHarness.cs:329`:
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
|
||||
```
|
||||
`Microsoft.AspNetCore.Mvc.Testing` is still referenced (csproj `:15`) but only for transitive
|
||||
test-host plumbing. **Copy `TwoNodeClusterHarness`, not `WebApplicationFactory`.** See D-5.
|
||||
|
||||
`Host.IntegrationTests` also has its own `docker-compose.yml` (sql `14331`, ldap `3894`) gated by
|
||||
`DockerFixtureAvailability.cs`.
|
||||
|
||||
### 8.3 docker-dev rig topology — plan's site-a/site-b assumption is **correct**
|
||||
|
||||
Single Akka mesh (`otopcua`), seeded by `central-1`. Tenant separation is by `ServerCluster.ClusterId`
|
||||
rows (MAIN / SITE-A / SITE-B) inside **one shared ConfigDb** — not separate meshes.
|
||||
|
||||
| Service | Roles | Cluster |
|
||||
|---|---|---|
|
||||
| `central-1`, `central-2` | `admin,driver` | MAIN |
|
||||
| `site-a-1`, `site-a-2` | `driver` | SITE-A |
|
||||
| `site-b-1`, `site-b-2` | `driver` | SITE-B |
|
||||
| `sql`, `migrator`, `cluster-seed`, `traefik` | — | infra |
|
||||
|
||||
**There is no admin-only service** — the "admin" nodes are fused `admin,driver`. All six host nodes
|
||||
share the `&otopcua-host` YAML anchor.
|
||||
|
||||
⚠ **NO HOST NODE HAS A WRITABLE VOLUME.** The only `volumes:` in the file are `sql` →
|
||||
`otopcua-mssql-data`, plus two read-only binds (`./seed:ro`, `./traefik-dynamic.yml:ro`). All six
|
||||
nodes write to the container's ephemeral layer, destroyed on recreate. This is already an accepted
|
||||
pattern (per-container `Secrets:SqlitePath=otopcua-secrets.db`, replicated over Akka rather than
|
||||
persisted). **Task 13 must add six named volumes** — there is nothing to piggyback on. See D-8.
|
||||
|
||||
**Env style: confirmed double-underscore `Section__Key`**, with array indices and dictionary keys as
|
||||
a third segment (`Cluster__SeedNodes__0`, `Security__Ldap__GroupToRole__ReadOnly`). Exceptions that
|
||||
are plain SCREAMING_SNAKE (read directly, not config-bound): `OTOPCUA_ROLES`,
|
||||
`ZB_SECRETS_MASTER_KEY`, `GALAXY_MXGW_API_KEY`, `OTOPCUA_CONFIG_CONNECTION`, `ASPNETCORE_URLS`.
|
||||
|
||||
**Ports.** Host-published: `14330`→sql, `4840`-`4845`→the six nodes' OPC UA, `9200`→traefik web,
|
||||
`8089`→traefik dashboard. Container-internal on every node: `4053` (Akka remoting), `9000`
|
||||
(Kestrel), `4840` (OPC UA). Also reserved on this machine: `14331`/`3894` (Host.IntegrationTests
|
||||
compose), `80`/`8080` (sibling scadabridge/scadalink stack), `10.100.0.35:3893` (shared GLAuth).
|
||||
|
||||
**`9001` (the plan's choice) is free** container-internal and need not be published. ✅
|
||||
|
||||
### 8.4 Package versions — `Directory.Packages.props`
|
||||
|
||||
| Package | Version | Line |
|
||||
|---|---|---|
|
||||
| `Grpc.Core.Api` | 2.76.0 | 32 |
|
||||
| `Grpc.Net.Client` | 2.76.0 | 33 |
|
||||
| **`Grpc.AspNetCore`** | **NOT PRESENT** | — |
|
||||
| `Google.Protobuf` | 3.34.1 | 31 |
|
||||
| `Microsoft.Data.Sqlite` | 10.0.7 | ~55 |
|
||||
| `SQLitePCLRaw.bundle_e_sqlite3` | 2.1.12 | ~108 |
|
||||
| `LiteDB` | 5.0.21 | 35 |
|
||||
|
||||
Both Grpc floors and the Protobuf floor are already satisfied — **no bumps needed**, only the new
|
||||
`Grpc.AspNetCore` entry (use 2.76.0 to match the train; watch for NU1605/NU1608 if it wants a
|
||||
Protobuf newer than the pinned 3.34.1).
|
||||
|
||||
⚠ **`SQLitePCLRaw` is a surgical direct pin, not a transitive one.**
|
||||
`CentralPackageTransitivePinningEnabled` is deliberately **off** (it breaks the Roslyn version
|
||||
split). The 2.1.12 pin is promoted by a **direct `PackageReference` in `Core.AlarmHistorian`**
|
||||
(`Core.AlarmHistorian.csproj:15` + `:21`) — today the only Sqlite consumer in `src/`.
|
||||
|
||||
> **Any new project that pulls `Microsoft.Data.Sqlite` MUST also take a direct
|
||||
> `<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>`,** or it silently resolves the
|
||||
> vulnerable 2.1.11 native bundle. This applies to **Host** and **Runtime** (both gain `ZB.MOM.WW.LocalDb`,
|
||||
> which depends on `Microsoft.Data.Sqlite`) and to every test project that builds a real `ILocalDb`.
|
||||
> Copy the `Core.AlarmHistorian.csproj:15,21` pair exactly. The plan's Task 1 mentions
|
||||
> `SQLitePCLRaw.lib.e_sqlite3` "if the audit flags it" — the correct package is
|
||||
> **`bundle_e_sqlite3`**, and it is **required, not conditional**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deviations from the plan
|
||||
|
||||
Recorded per the tasks.json `deviation` convention. **D-1, D-3, D-5, D-6 change the work
|
||||
materially.**
|
||||
|
||||
| # | Plan says | Reality | Resolution |
|
||||
|---|---|---|---|
|
||||
| **D-1** | Cache keyed by `ClusterId`; "cluster identity" is a recon item with a STOP condition | `ClusterId` is stable, but **not resolvable at the boot seam** — it lives inside the artifact or in the unreachable central DB (§4) | Keep `cluster_id` as PK (pair sharing is the point). **Write:** resolve via `DeploymentArtifact.ResolveClusterScope`, `"__single"` sentinel for the degenerate case. **Read:** unkeyed `ORDER BY applied_at_utc DESC LIMIT 1`, warn if >1 row. No STOP. |
|
||||
| **D-2** | Cache write "fire-and-forget (`PipeTo`-style or guarded `Task.Run`)" | The actor has **no async DB IO at all**; a `PipeTo(Self)` needs handlers in all three states or it dead-letters after `Become(Steady)` (§5.4) | Synchronous write in its own try/catch at `:1439`. Matches the file's style; failure still cannot fail the apply. |
|
||||
| **D-3** | Task 9 deletes 2 test files | **3** test files reference the subsystem — `LiteDbConfigCacheTests.cs` is missing from the list (§7.3) | Delete all three. Also fix the stale XML-doc mention at `ILdapGroupRoleMappingService.cs:14`. |
|
||||
| **D-4** | Cache the artifact after a successful apply | `ReconcileDrivers`/`PushDesiredSubscriptions` **swallow** DB errors, so a "successful" apply can have loaded an **empty blob** and started zero drivers (§2.3) | Gate the write on a non-empty blob; surface the blob from `ReconcileDrivers` instead of a third read. **Load-bearing — without it the cache can be poisoned with an empty config.** |
|
||||
| **D-5** | Task 11 uses `WebApplicationFactory<Program>` | Deliberately **not used** in this repo; `TwoNodeClusterHarness.cs:48` documents why (Program.cs reads `OTOPCUA_ROLES` from the environment) (§8.2) | Rewrite Task 11 against the `TwoNodeClusterHarness` direct-host-build pattern. |
|
||||
| **D-6** | Task 5 re-binds `ASPNETCORE_URLS ?? "http://+:9000"` | `Install-Services.ps1:185-186` sets `ASPNETCORE_URLS` **only when `hasAdmin`** — the fallback would bind a driver-only node to a port it never served (§6.4) | Re-bind only when a URL is actually configured; when absent, add **only** the sync listener. Also note `TwoNodeClusterHarness.cs:331` already calls `UseKestrel(Listen(…, 0))`. |
|
||||
| **D-7** | Task 10 driver-gates the health check | `AddOtOpcUaHealth()` takes no args and is called unconditionally at `Program.cs:365`; no registration-time gating exists anywhere (§6.7) | Register unconditionally; return `Healthy` when LocalDb is absent — the `ActiveNodeHealthCheck` precedent. Avoids a signature change and satisfies "default-OFF must not degrade a plain node". |
|
||||
| **D-8** | Task 13 "existing data mount" | **No host node has any writable volume** (§8.3) | Add six named volumes. Note: `9001` is free container-internal. Meter allowlist (§6.6) is **required**, not conditional. |
|
||||
|
||||
### Follow-ups logged, not fixed here
|
||||
|
||||
1. **`TryRecoverFromStale` marks `Applied` without reconciling drivers** (§3.5) — pre-existing,
|
||||
documented in-code at `:1692-1695`. Out of Phase 1 scope.
|
||||
2. **Duplicate blob read per apply** (`:1472` and `:1543`, §1.1) — D-4's fix removes one of them as a
|
||||
side effect.
|
||||
3. **`Install-Services.ps1` gives driver-only nodes no `ASPNETCORE_URLS`** (§6.4) — pre-existing;
|
||||
D-6 works around it rather than fixing it.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Issue #485 — live gate (docker-dev rig)
|
||||
|
||||
> Verifies the two fixes for #485 — `OpcUaPublishActor` (master `c7f5b9cf`) and `DriverHostActor`
|
||||
> (master `5aad1e33`) — on the local `docker-dev` rig: 6 nodes, shared SQL, real Modbus fixture on
|
||||
> `10.100.0.35:5020`.
|
||||
>
|
||||
> **Safety rules followed**: all DB inspection + mutation via `sqlcmd` inside the SQL container; the
|
||||
> rig's Modbus fixture is read-only for this gate (no writes to the PLC sim).
|
||||
|
||||
## Result
|
||||
|
||||
**Both fixes verified, all three guards observed firing in production, with a live RED control that
|
||||
was not staged.** The gate also confirmed a **pre-existing** defect the fixes do not address — the node
|
||||
records `Applied` for a deployment it skipped (see "Finding" below).
|
||||
|
||||
| # | Check | Result | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | RED control — the bug in its natural habitat, unforced | ✅ | site-a-1 hit #485 during the *previous* gate's SQL flapping and had been serving an **empty address space for 34 minutes**: `failed to load artifact … rebuild becomes no-op` followed immediately by `applied plan (kind=PureRemove, added=0, removed=17, rebuild=True)`. Its 17 `ns=2` nodes were gone; a browse returned only the root `OtOpcUa` folder. Nothing was staged to produce this — it was already true on the pre-fix image. |
|
||||
| 2 | Rebuilt on the fixed image, the node recovers | ✅ | After `docker compose build` + `--force-recreate`, site-a-1 restored to `raw subtree materialised (containers=16, tags=1)` — 18 `ns=2` nodes. |
|
||||
| 3 | Address-space guard fires; nodes survive | ✅ | site-a-1, empty artifact: `OpcUaPublish: no usable artifact for deployment f4b9dd63… — KEEPING the currently served address space rather than tearing it down`. **No `PureRemove`.** Browse before vs after: 18 vs 18, `diff` **identical**. |
|
||||
| 4 | Reconcile guard fires; drivers survive | ✅ | `artifact for f4b9dd63… read back empty; skipping reconcile and keeping the 1 running driver(s)`, and the apply still reported `children=1`. Repeated on central-2 with a bigger blast radius: `keeping the 5 running driver(s)`, `children=5`. |
|
||||
| 5 | SubscribeBulk guard fires; **live values keep flowing** | ✅ | central-2, `artifact for 8c36a673… read back empty; skipping SubscribeBulk and keeping the live subscriptions`. The decisive proof is the value, not the log: `ns=2;s=pymodbus/plc/HR200X` read **56278 @ 06:59:51** before the induction, then **56336 @ 07:00:21** and **56354 @ 07:00:30** after it (induction at 07:00:07). The subscription was still delivering fresh Modbus data across the event. |
|
||||
| 6 | Positive control — a readable artifact still applies | ✅ | Immediately after the guards fired, a genuine deploy applied normally on both nodes: site-a-1 `AddRemoveMix, added=1, removed=1` + `SubscribeBulk pushed 1 references`; central-2 `AddRemoveMix, added=1, removed=1` + `SubscribeBulk pushed 5 references across 4 driver(s)`. The guards suppress the teardown, **not** deployment. |
|
||||
| 7 | Control — only the node that saw empty bytes took the guard path | ✅ | Of the six nodes, exactly **one** logged `read back empty` (the paused one); the other five read the real artifact before it was blanked and applied it normally. The induction window is what produced the condition, not a global change. |
|
||||
|
||||
## How the failure was induced deterministically
|
||||
|
||||
The original incident was a race (a transient ConfigDb blip landing between the apply and the artifact
|
||||
read). Racing it again would be unreliable, so the gate drives the **empty-bytes** branch instead — which
|
||||
is byte-for-byte what the code sees when the row cannot be read, and is the branch the driver-side guards
|
||||
key on (their *throw* paths already returned early before this work):
|
||||
|
||||
1. `docker pause` the target node, freezing it mid-cluster.
|
||||
2. `POST /api/deployments` — the deployment seals and dispatches while the node cannot process it.
|
||||
3. `UPDATE Deployment SET ArtifactBlob = 0x WHERE DeploymentId = <new>`.
|
||||
4. `docker unpause` — the node now processes the dispatch and reads an empty artifact.
|
||||
|
||||
The pause measured **<1s** on both runs, well inside `acceptable-heartbeat-pause = 10s` and SBR's
|
||||
`stable-after = 15s`, so the node was never flagged unreachable and no failover was triggered.
|
||||
|
||||
## Finding — the node records `Applied` for a deployment it skipped (pre-existing, NOT fixed here)
|
||||
|
||||
Both guards leave the node running its previous configuration, which is the intent. But `ApplyAndAck`
|
||||
advances `_currentRevision = revision` and writes `NodeDeploymentState = Applied` **before** it knows
|
||||
whether anything was applied — that assignment sits immediately after `ReconcileDrivers`, which returns
|
||||
`null` on both the (pre-existing) throw path and the new empty path:
|
||||
|
||||
```
|
||||
[07:00:07 WRN] DriverHost central-2:4053: artifact … read back empty; skipping reconcile …
|
||||
[07:00:07 INF] DriverHost central-2:4053: applied deployment 8c36a673… (rev 03b33904…, children=5)
|
||||
```
|
||||
|
||||
`NodeDeploymentState` for that deployment reads `Status = Applied` for central-2. The node now claims a
|
||||
revision whose configuration it never applied — and because `HandleDispatchFromSteady` short-circuits on
|
||||
a revision match (`dispatch matches current rev; immediate ACK`), **re-dispatching that same revision is
|
||||
a no-op**, so the node cannot self-heal. Only a later deployment with a *different* revision recovers it
|
||||
(check 6 confirms that path works).
|
||||
|
||||
This is pre-existing — the throw path has always done it — and is strictly a reporting/convergence bug,
|
||||
not a data-plane one: the node keeps serving its last good configuration throughout. The honest fix is to
|
||||
ACK `Failed` and leave `_currentRevision` alone when the artifact could not be read, so the normal deploy
|
||||
retry heals it. Tracked separately rather than folded in here, because it changes deploy ACK semantics
|
||||
fleet-wide.
|
||||
|
||||
## Addendum — issue #486 fix, live-gated (2026-07-21)
|
||||
|
||||
The finding above is now fixed (`DriverHostActor.ApplyAndAck` fails the apply when `ReconcileDrivers`
|
||||
returns null) and gated on the same rig with the same induction.
|
||||
|
||||
| # | Check | Result | Evidence |
|
||||
|---|---|---|---|
|
||||
| 8 | Regression — a normal deploy still ACKs Applied fleet-wide | ✅ | The change alters ACK semantics, so this is the risk that matters. A genuine deploy recorded `Status = Applied` on **all six** nodes. |
|
||||
| 9 | An unreadable artifact now ACKs Failed, with a reason | ✅ | central-2 recorded `Status = Failed` + `the deployment artifact could not be read (ConfigDb unreachable, or the row is missing/empty); nothing was applied`. The five nodes that read the real artifact recorded Applied — so the fleet view now distinguishes them. Pre-fix, all six claimed Applied. |
|
||||
| 10 | The revision is NOT advanced | ✅ | `apply of a855dd9e… FAILED — … Staying on revision 5bc0d9f3…` — the **previous** revision, not the dispatched `583122b7…`. This is what stops `HandleDispatchFromSteady` from short-circuiting a retry of that revision. |
|
||||
| 11 | The node keeps serving throughout (#485 still holds) | ✅ | The #485 guard fired first (`keeping the 5 running driver(s)`), and `ns=2;s=pymodbus/plc/HR200X` read **59603 @ 07:27:28**, live and current, after the Failed apply. Failing the apply reports the truth; it does not tear anything down. |
|
||||
| 12 | The node recovers on the next real deploy | ✅ | The following deployment applied on central-2 (`children=5`) and recorded Applied on all six nodes. |
|
||||
@@ -0,0 +1,86 @@
|
||||
# LocalDb Phase 1 follow-ups — live gate (docker-dev rig)
|
||||
|
||||
> Closes the two limitations left open by `2026-07-20-localdb-phase1-live-gate.md` (checks 4 and 8).
|
||||
> Rig: local `docker-dev/docker-compose.yml`, 6 nodes rebuilt from `fix/localdb-phase1-followups`.
|
||||
> site-a pair replicates; central + site-b stay default-OFF.
|
||||
>
|
||||
> **Safety rules followed** (same as the Phase 1 gate): all DB inspection via `docker cp` of the
|
||||
> `db`/`-wal`/`-shm` triplet, querying the copy — never host `sqlite3` on the live WAL file.
|
||||
|
||||
## Result
|
||||
|
||||
**Both follow-ups closed, and the gate found a third defect** — one that was *unreachable* until the
|
||||
first fix landed. Library `ZB.MOM.WW.LocalDb` went `0.1.1` → `0.1.2` → `0.1.3` in the course of this
|
||||
gate; both consumers (OtOpcUa, ScadaBridge) are pinned to `0.1.3`.
|
||||
|
||||
| # | Check | Result | Evidence |
|
||||
|---|---|---|---|
|
||||
| 8 | Oplog stops growing on a default-OFF node | ✅ PASS | site-b-1 restarted twice with an unchanged cached artifact: `oplog_depth` **7 → 7 → 7**, `deployment_pointer.applied_at_utc` frozen at `05:34:58` across both — the re-cache was skipped outright. Repeated on the `0.1.3` build after a real deploy: **13 → 13**. Pre-fix this grew ~2 rows per restart forever, because a default-OFF node has no peer to ack and prune them. |
|
||||
| 8b | …but a genuinely new deployment still writes (positive control) | ✅ PASS | A real config change (renamed tag `SITEA-tag`) produced rev `822bdf34`, then `002c16b3`. site-b-1's oplog **7 → 10 → 13** and its pointer advanced each time. The skip suppresses *identical* re-writes only; it is not a dead cache-write path. |
|
||||
| 4 | A wiped node is back-filled by its peer, no new deploy, central down | ✅ PASS (was ⚠️ PARTIAL) | Precondition asserted first — site-a-1's oplog **0**, `needs_snapshot 0`: the exact state that used to make back-fill impossible. Then SQL stopped, site-a-2's LocalDb **volume destroyed**, container recreated. a-1 logged `Snapshot sent (as_of_seq 0, 4 rows)` — a line that could not appear on `0.1.1`. a-2 came back with pointer `SITE-A / 475df87a @ 03:09:12` (a-1's **original** timestamp, not a re-derived one), chunks 2, and a `__localdb_row_version` dump **byte-identical to a-1's**, carrying origin node ids `6812ca9c` and `935659a5` — the rebuilt node's own id appears nowhere, so the rows were replicated, not recreated. |
|
||||
| 4b | …and the back-filled node then boots from cache | ✅ PASS | With SQL still down, site-a-2 logged `RUNNING FROM CACHE — … booted deployment 475df87a… cached at 2026-07-21T03:09:12`, `raw subtree materialised (containers=16, tags=1)`. OPC UA browse of both nodes: **17 ns=2 nodes each, diff-identical** — the rebuilt node serves exactly what its healthy peer does, from a configuration it never applied itself and could not have fetched. |
|
||||
| 4c | The rebuilt node's OWN writes reach its peer | ✅ PASS **after a third fix** (see below) | On `0.1.2` this failed: a-1 held `last_applied_remote = 10` while the rebuilt a-2's oplog was seqs **1,2,3** and its `last_acked` stayed **0** — never accepted. On `0.1.3`, a-1 logs `Replication peer identity changed (ebb10854 -> c85d8b1d): the peer was rebuilt … Resetting the inbound watermark`, and after the next deploy a-1 shows `last_applied_remote = 3` / a-2 shows `last_acked = 3` with a drained oplog and identical row_versions. |
|
||||
|
||||
## The third defect (the reason this gate was worth running)
|
||||
|
||||
`0.1.2` made back-fill work, which for the first time let a rebuilt node be observed *writing*. It
|
||||
turned out its writes went nowhere.
|
||||
|
||||
`last_applied_remote_seq` is a watermark in the **peer's** seq space, and a rebuilt peer is a new
|
||||
database whose oplog numbers from 1. Two independent bugs stacked, and fixing either alone leaves the
|
||||
data stuck:
|
||||
|
||||
1. The healthy node kept advertising the **old** peer's watermark. The *sending* side seeds its pump
|
||||
directly from that number (`_sentThruSeq`), so the rebuilt node skipped its entire oplog.
|
||||
→ the watermark (and the observed peer clock) is now reset when the peer's node id changes.
|
||||
`last_acked_seq` is deliberately **not** reset: it describes our own oplog's pruned horizon and is
|
||||
exactly what `0.1.2` keys the snapshot decision off — clearing it would switch back-fill back off.
|
||||
2. A peer claiming to have applied more of our stream than we have ever produced can only be
|
||||
remembering a previous incarnation of *us*. → that claim is now rejected in favour of pumping from
|
||||
the start; LWW makes the re-send harmless. This mirrors the clamp `RecordPeerAckAsync` already
|
||||
applies to acks.
|
||||
|
||||
Both were reproduced offline as a RED test before fixing
|
||||
(`SnapshotResyncTests.RebuiltPeer_OwnWritesReachTheHealthyNode_DespiteTheOldPeersWatermark`).
|
||||
|
||||
**Why offline tests could not have found it first:** the wiped-peer scenario had no test because the
|
||||
scenario was believed impossible to recover from — it was a *documented limitation*, not a bug. The
|
||||
gate is what turned the limitation into a reproducible case, and the second defect only existed
|
||||
behind the first.
|
||||
|
||||
## Unrelated finding (NOT a follow-up of this work — pre-existing; `lmxopcua` issue #485, since FIXED)
|
||||
|
||||
While SQL was being stopped and started to drive check 4, site-b-1 took a deploy whose artifact load
|
||||
hit a transient ConfigDb error, and **emptied its served address space**:
|
||||
|
||||
```
|
||||
[06:11:20 ERR] An error occurred using the connection to database 'OtOpcUa' on server 'sql,1433'.
|
||||
[06:11:20 WRN] OpcUaPublish: failed to load artifact for deployment 43d54397…; rebuild becomes no-op
|
||||
[06:11:20 INF] AddressSpaceApplier: applied plan (kind=PureRemove, added=0, removed=16, rebuild=True)
|
||||
```
|
||||
|
||||
The log says "rebuild becomes no-op" and the applier then removed all 16 nodes — those two disagree.
|
||||
The cache layer behaved correctly and refused to store the bad artifact ("not caching deployment … its
|
||||
artifact was empty or could not be loaded, so it is not a usable last-known-good configuration"), and
|
||||
the node recovered fully on restart (`containers=16`). But a transient ConfigDb blip emptying the
|
||||
served address space instead of holding last-known-good is the same failure *class* as the Phase 1
|
||||
gate's check-3 defect. Untouched by this work — site-b-1 has replication off entirely — and induced by
|
||||
this gate's own SQL flapping.
|
||||
|
||||
**Fixed** (issue #485): `OpcUaPublishActor.HandleRebuild` now treats an artifact it could not obtain as
|
||||
*no answer* rather than *an empty configuration*, and abandons the rebuild with the served address space
|
||||
(and `_lastApplied`) intact. Nothing legitimate produces a zero-length blob — an operator who really
|
||||
deletes everything still deploys a JSON document with empty arrays — so "no bytes" can only mean the read
|
||||
failed. The log excerpt above is now impossible: the `PureRemove` never runs, and the loader's own
|
||||
"the rebuild is abandoned" line is finally true. Covered by
|
||||
`OpcUaPublishActorRebuildTests.Rebuild_keeps_the_last_known_good_address_space_when_the_artifact_load_fails`,
|
||||
with a positive control proving a *readable* artifact that genuinely drops an equipment still tears its
|
||||
subtree down.
|
||||
|
||||
The **driver-side half** of the same mistake was fixed alongside it: `DriverHostActor.ReconcileDrivers`
|
||||
and `PushDesiredSubscriptionsFromArtifact` read the artifact the same way, so empty bytes meant zero
|
||||
driver specs — `DriverSpawnPlanner` planned every running child for `StopChild` — and then an empty
|
||||
desired set dropped each surviving driver's live subscription handle. A node lost its entire field I/O
|
||||
and still ACKed Applied. Both now skip on empty. Covered by `DriverHostActorUnreadableArtifactTests`,
|
||||
whose absence assertion is calibrated by a control that observes the very same unsubscribe *arriving*
|
||||
inside the settle window (without it the assertion passed on a race).
|
||||
@@ -380,6 +380,55 @@ polling the node.
|
||||
|
||||
---
|
||||
|
||||
## Config secrets (`${secret:}` delivery)
|
||||
|
||||
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
|
||||
supplied at deploy time — either as a plain environment variable, or as a
|
||||
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
|
||||
|
||||
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
|
||||
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
|
||||
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
|
||||
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
|
||||
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
|
||||
the encrypted SQLite store lives at `Secrets:SqlitePath`.
|
||||
|
||||
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
|
||||
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
|
||||
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
|
||||
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
|
||||
|
||||
The five config secrets and their canonical secret names:
|
||||
|
||||
| Config key | Owner | Secret name |
|
||||
|---|---|---|
|
||||
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
|
||||
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
|
||||
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
|
||||
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
|
||||
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
|
||||
|
||||
**Delivery.** By default these are delivered as plain environment variables (e.g.
|
||||
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
|
||||
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
|
||||
the token in place of the literal — via env var or a deployment appsettings overlay:
|
||||
|
||||
```
|
||||
secret set otopcua/historian/api-key <value> # seed the encrypted store first
|
||||
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
|
||||
```
|
||||
|
||||
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
|
||||
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
|
||||
secret value.
|
||||
|
||||
For the production posture needed so every clustered node (admin, driver, and any
|
||||
fused role) resolves the same secrets from the same store, see
|
||||
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed record DriverInstanceDiagnostics(
|
||||
/// Per-node diagnostics returned by <c>IFleetDiagnosticsClient</c>. Populated by the node's
|
||||
/// local <c>DriverHostActor</c> via a request/response over Akka.
|
||||
/// </summary>
|
||||
/// <param name="RunningFromCache">
|
||||
/// True when the node booted its configuration from the node-local artifact cache because the
|
||||
/// central ConfigDb was unreachable. Such a node looks entirely healthy — full address space,
|
||||
/// live values — but its configuration is frozen and no deployment can reach it, so the state
|
||||
/// needs to be explicitly visible rather than inferred. Defaults to false, which is also what
|
||||
/// every node that booted normally reports.
|
||||
/// </param>
|
||||
public sealed record NodeDiagnosticsSnapshot(
|
||||
NodeId NodeId,
|
||||
RevisionHash? CurrentRevision,
|
||||
IReadOnlyList<DriverInstanceDiagnostics> Drivers,
|
||||
DateTime AsOfUtc);
|
||||
DateTime AsOfUtc,
|
||||
bool RunningFromCache = false);
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
||||
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
||||
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
||||
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
|
||||
public sealed record AlarmTransitionEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
|
||||
DateTime TimestampUtc,
|
||||
string AlarmTypeName = "AlarmCondition",
|
||||
string? Comment = null,
|
||||
bool? HistorizeToAveva = null);
|
||||
bool? HistorizeToAveva = null,
|
||||
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
|
||||
/// node's <c>s=</c> identifier at every address-space sink seam so the namespace is chosen at
|
||||
/// the call site, never parsed back out of the id string (explicit beats inferred).
|
||||
/// <see cref="V3NodeIds.NamespaceUri"/> maps each realm to its namespace URI.
|
||||
/// </summary>
|
||||
public enum AddressSpaceRealm
|
||||
{
|
||||
/// <summary>The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
|
||||
/// <c>s=</c> id is its <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.RawPaths">RawPath</see>.</summary>
|
||||
Raw,
|
||||
|
||||
/// <summary>The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's <c>s=</c>
|
||||
/// id is the slash-joined <c>Area/Line/Equipment[/EffectiveName]</c>.</summary>
|
||||
Uns,
|
||||
}
|
||||
@@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
|
||||
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
|
||||
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
|
||||
/// <param name="Quality">
|
||||
/// Quality of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
||||
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
||||
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||
/// <see cref="OpcUaQuality.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
||||
/// </param>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
ushort Severity,
|
||||
string Message);
|
||||
string Message,
|
||||
OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
|
||||
/// <summary>
|
||||
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
||||
|
||||
@@ -23,30 +23,44 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
@@ -55,9 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// Without this forward the surgical optimization is inert on every driver-role host, because
|
||||
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
||||
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
@@ -65,9 +79,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||
// actors inject THIS wrapper, not the inner sink.
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||
|
||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||
@@ -75,14 +89,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
||||
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
|
||||
/// <inheritdoc />
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
|
||||
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
|
||||
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
|
||||
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
|
||||
/// three agree on the exact NodeId a variable was placed at.
|
||||
/// </summary>
|
||||
public static class EquipmentNodeIds
|
||||
{
|
||||
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
|
||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
||||
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
|
||||
/// <returns>The sub-folder NodeId string.</returns>
|
||||
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
|
||||
|
||||
/// <summary>
|
||||
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
|
||||
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
||||
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
|
||||
/// <returns>The folder-scoped variable NodeId string.</returns>
|
||||
public static string Variable(string equipmentId, string? folderPath, string name)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
|
||||
return $"{parent}/{name}";
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="quality">The quality status of the value.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
||||
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
|
||||
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
||||
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
||||
@@ -25,7 +29,20 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc);
|
||||
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
|
||||
/// the condition was materialised under).</param>
|
||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>,
|
||||
/// restored → <see cref="OpcUaQuality.Good"/>). Sets ONLY the condition's Quality — never
|
||||
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
||||
/// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node.</summary>
|
||||
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="quality">The source-data quality to annotate.</param>
|
||||
/// <param name="sourceTimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||
/// <param name="realm">The namespace realm the condition was materialised under.</param>
|
||||
void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>
|
||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||
@@ -41,7 +58,31 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
||||
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
|
||||
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
|
||||
/// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
|
||||
/// live in.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
|
||||
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
|
||||
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
|
||||
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
|
||||
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
|
||||
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
|
||||
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
|
||||
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
|
||||
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
|
||||
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
|
||||
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
|
||||
/// across redeploys).
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
|
||||
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
|
||||
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
|
||||
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
|
||||
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
|
||||
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
||||
@@ -52,7 +93,8 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||
/// <param name="displayName">The display name for the folder.</param>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
/// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||
@@ -77,7 +119,10 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// rank + dimensions carry the array-ness.</param>
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
|
||||
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
||||
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
||||
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
||||
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
||||
|
||||
/// <summary>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||
@@ -91,7 +136,28 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
||||
/// </summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
void RaiseNodesAddedModelChange(string affectedNodeId);
|
||||
/// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
|
||||
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>
|
||||
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
|
||||
/// target node, both realm-qualified. Used by <c>AddressSpaceApplier</c> to link each UNS reference
|
||||
/// Variable (source, <see cref="AddressSpaceRealm.Uns"/>) to its backing Raw node (target,
|
||||
/// <see cref="AddressSpaceRealm.Raw"/>) with an <c>Organizes</c> edge, so the UNS variable
|
||||
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
|
||||
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
|
||||
/// <b>Idempotent</b> (an existing edge is not duplicated); a <b>missing endpoint is a no-op</b>
|
||||
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeId">The source node's <c>s=</c> id (the referencing node — e.g. the UNS variable).</param>
|
||||
/// <param name="sourceRealm">The namespace realm <paramref name="sourceNodeId"/> lives in.</param>
|
||||
/// <param name="targetNodeId">The target node's <c>s=</c> id (the referenced node — e.g. the backing Raw node).</param>
|
||||
/// <param name="targetRealm">The namespace realm <paramref name="targetNodeId"/> lives in.</param>
|
||||
/// <param name="referenceType">The hierarchical reference type name — <c>Organizes</c> (default),
|
||||
/// <c>HasComponent</c>, or <c>HasProperty</c>; unknown names fall back to <c>Organizes</c>.</param>
|
||||
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
|
||||
string targetNodeId, AddressSpaceRealm targetRealm,
|
||||
string referenceType = "Organizes");
|
||||
}
|
||||
|
||||
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
||||
@@ -106,23 +172,30 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
private NullOpcUaAddressSpaceSink() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
|
||||
{
|
||||
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
||||
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
||||
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
|
||||
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
|
||||
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
|
||||
/// routing: a raw <c>s=<RawPath></c> and a UNS <c>s=<Area/Line/Equip/Eff></c> can collide as bare
|
||||
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
|
||||
/// operator write route to the WRONG driver ref.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task resolving to the device-write outcome.</returns>
|
||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
|
||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
||||
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
||||
private NullOpcUaNodeWriteGateway() { }
|
||||
/// <inheritdoc />
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
|
||||
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||||
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
|
||||
/// namespace index from the realm rather than parsing it out of the id string.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
||||
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength);
|
||||
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
||||
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
||||
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// should rebuild instead).</summary>
|
||||
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
||||
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
|
||||
bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
|
||||
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
|
||||
@@ -43,8 +46,9 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
|
||||
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
|
||||
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
|
||||
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
|
||||
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||
bool RemoveVariableNode(string variableNodeId);
|
||||
bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
|
||||
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
|
||||
@@ -52,8 +56,9 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
|
||||
/// rebuilds).</summary>
|
||||
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
|
||||
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
|
||||
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||
bool RemoveAlarmConditionNode(string alarmNodeId);
|
||||
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
|
||||
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
|
||||
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
||||
/// rebuilds).</summary>
|
||||
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
|
||||
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
|
||||
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||
bool RemoveEquipmentSubtree(string equipmentNodeId);
|
||||
bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
|
||||
/// underlying values under two identity schemes:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
|
||||
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
|
||||
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
|
||||
/// on its own RawPath prefix).</item>
|
||||
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
|
||||
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
|
||||
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
|
||||
/// </list>
|
||||
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
|
||||
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
|
||||
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
|
||||
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
|
||||
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
|
||||
/// </summary>
|
||||
public static class V3NodeIds
|
||||
{
|
||||
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
|
||||
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
|
||||
|
||||
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
|
||||
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
|
||||
|
||||
/// <summary>The namespace URI for a realm.</summary>
|
||||
/// <param name="realm">The address-space realm.</param>
|
||||
/// <returns>The realm's namespace URI.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
|
||||
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
|
||||
{
|
||||
AddressSpaceRealm.Raw => RawNamespaceUri,
|
||||
AddressSpaceRealm.Uns => UnsNamespaceUri,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
|
||||
};
|
||||
|
||||
// ----- Raw realm -----
|
||||
|
||||
/// <summary>
|
||||
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
|
||||
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
|
||||
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
|
||||
/// string; the argument is expected to already be a RawPaths-built path.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The (already-built) RawPath.</param>
|
||||
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
|
||||
public static string Raw(string rawPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rawPath);
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
// ----- Uns realm -----
|
||||
|
||||
/// <summary>
|
||||
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
|
||||
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
|
||||
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
|
||||
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
|
||||
/// </summary>
|
||||
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
|
||||
public static string Uns(params string[] segments) => UnsFromSegments(segments);
|
||||
|
||||
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
|
||||
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
|
||||
|
||||
/// <summary>
|
||||
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
|
||||
/// EffectiveName under an Equipment) to an already-built UNS path.
|
||||
/// </summary>
|
||||
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
|
||||
/// <param name="childSegment">The child segment to append.</param>
|
||||
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
|
||||
public static string UnsChild(string parentUnsPath, string childSegment)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
|
||||
var error = RawPaths.ValidateSegment(childSegment);
|
||||
if (error is not null)
|
||||
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
|
||||
|
||||
return parentUnsPath + RawPaths.Separator + childSegment;
|
||||
}
|
||||
|
||||
private static string UnsFromSegments(IEnumerable<string> segments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(segments);
|
||||
var list = segments as IReadOnlyList<string> ?? segments.ToList();
|
||||
if (list.Count == 0)
|
||||
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
|
||||
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var error = RawPaths.ValidateSegment(list[i]);
|
||||
if (error is not null)
|
||||
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
|
||||
}
|
||||
|
||||
return string.Join(RawPaths.Separator, list);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> and Phase 6.1
|
||||
/// Stream D.1. Each published generation writes one <b>read-only</b> LiteDB file under
|
||||
/// <c><cache-root>/<clusterId>/<generationId>.db</c>. A per-cluster
|
||||
/// <c>CURRENT</c> text file holds the currently-active generation id; it is updated
|
||||
/// atomically (temp file + <see cref="File.Replace(string, string, string?)"/>) only after
|
||||
/// the sealed file is fully written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Mixed-generation reads are impossible: any read opens the single file pointed to
|
||||
/// by <c>CURRENT</c>, which is a coherent snapshot. Corruption of the CURRENT file or the
|
||||
/// sealed file surfaces as <see cref="GenerationCacheUnavailableException"/> — the reader
|
||||
/// fails closed rather than silently falling back to an older generation. Recovery path
|
||||
/// is to re-fetch from the central DB (and the Phase 6.1 Stream C <c>UsingStaleConfig</c>
|
||||
/// flag goes true until that succeeds).</para>
|
||||
///
|
||||
/// <para>This cache is the read-path fallback when the central DB is unreachable. The
|
||||
/// write path (draft edits, publish) bypasses the cache and fails hard on DB outage per
|
||||
/// Stream D.2 — inconsistent writes are worse than a temporary inability to edit.</para>
|
||||
/// </remarks>
|
||||
public sealed class GenerationSealedCache
|
||||
{
|
||||
private const string CollectionName = "generation";
|
||||
private const string CurrentPointerFileName = "CURRENT";
|
||||
private readonly string _cacheRoot;
|
||||
|
||||
// Private per-database BsonMapper with the entity pre-registered. LiteDB's default
|
||||
// BsonMapper.Global is a process-wide singleton whose lazy per-type member registration is
|
||||
// not thread-safe across concurrently-constructed LiteDatabase instances; a seal racing a
|
||||
// read (or this cache racing LiteDbConfigCache) corrupts the global mapper, surfacing as
|
||||
// "Member … not found on BsonMapper" or a bogus duplicate-_id insert.
|
||||
private static BsonMapper BuildMapper()
|
||||
{
|
||||
var mapper = new BsonMapper();
|
||||
mapper.Entity<GenerationSnapshot>();
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/// <summary>Root directory for all clusters' sealed caches.</summary>
|
||||
public string CacheRoot => _cacheRoot;
|
||||
|
||||
/// <summary>Initializes a new instance of the GenerationSealedCache class.</summary>
|
||||
/// <param name="cacheRoot">The root directory for the cache.</param>
|
||||
public GenerationSealedCache(string cacheRoot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheRoot);
|
||||
_cacheRoot = cacheRoot;
|
||||
Directory.CreateDirectory(_cacheRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seal a generation: write the snapshot to <c><cluster>/<generationId>.db</c>,
|
||||
/// mark the file read-only, then atomically publish the <c>CURRENT</c> pointer. Existing
|
||||
/// sealed files for prior generations are preserved (prune separately).
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The generation snapshot to seal.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task SealAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var clusterDir = Path.Combine(_cacheRoot, snapshot.ClusterId);
|
||||
Directory.CreateDirectory(clusterDir);
|
||||
var sealedPath = Path.Combine(clusterDir, $"{snapshot.GenerationId}.db");
|
||||
|
||||
if (File.Exists(sealedPath))
|
||||
{
|
||||
// Already sealed — idempotent. Treat as no-op + update pointer in case an earlier
|
||||
// seal succeeded but the pointer update failed (crash recovery).
|
||||
WritePointerAtomically(clusterDir, snapshot.GenerationId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tmpPath = sealedPath + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var db = new LiteDatabase(new ConnectionString { Filename = tmpPath, Upgrade = false }, BuildMapper()))
|
||||
{
|
||||
var col = db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
col.Insert(snapshot);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, sealedPath);
|
||||
File.SetAttributes(sealedPath, File.GetAttributes(sealedPath) | FileAttributes.ReadOnly);
|
||||
WritePointerAtomically(clusterDir, snapshot.GenerationId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current sealed snapshot for <paramref name="clusterId"/>. Throws
|
||||
/// <see cref="GenerationCacheUnavailableException"/> when the pointer is missing
|
||||
/// (first-boot-no-snapshot case) or when the sealed file is corrupt. Never silently
|
||||
/// falls back to a prior generation.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The cluster ID to read the snapshot for.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation containing the generation snapshot.</returns>
|
||||
public Task<GenerationSnapshot> ReadCurrentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var clusterDir = Path.Combine(_cacheRoot, clusterId);
|
||||
var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
|
||||
if (!File.Exists(pointerPath))
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"No sealed generation for cluster '{clusterId}' at '{clusterDir}'. First-boot case: the central DB must be reachable at least once before cache fallback is possible.");
|
||||
|
||||
long generationId;
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(pointerPath).Trim();
|
||||
generationId = long.Parse(text, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"CURRENT pointer at '{pointerPath}' is corrupt or unreadable.", ex);
|
||||
}
|
||||
|
||||
var sealedPath = Path.Combine(clusterDir, $"{generationId}.db");
|
||||
if (!File.Exists(sealedPath))
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"CURRENT points at generation {generationId} but '{sealedPath}' is missing — fails closed rather than serving an older generation.");
|
||||
|
||||
try
|
||||
{
|
||||
using var db = new LiteDatabase(new ConnectionString { Filename = sealedPath, ReadOnly = true }, BuildMapper());
|
||||
var col = db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
var snapshot = col.FindAll().FirstOrDefault()
|
||||
?? throw new GenerationCacheUnavailableException(
|
||||
$"Sealed file '{sealedPath}' contains no snapshot row — file is corrupt.");
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
catch (GenerationCacheUnavailableException) { throw; }
|
||||
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||
or NotSupportedException or FormatException)
|
||||
{
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"Sealed file '{sealedPath}' is corrupt or unreadable — fails closed rather than falling back to an older generation.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Return the generation id the <c>CURRENT</c> pointer points at, or null if no pointer exists.</summary>
|
||||
/// <param name="clusterId">The cluster ID to get the current generation ID for.</param>
|
||||
/// <returns>The generation ID, or null if no pointer exists.</returns>
|
||||
public long? TryGetCurrentGenerationId(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
var pointerPath = Path.Combine(_cacheRoot, clusterId, CurrentPointerFileName);
|
||||
if (!File.Exists(pointerPath)) return null;
|
||||
try
|
||||
{
|
||||
return long.Parse(File.ReadAllText(pointerPath).Trim(), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePointerAtomically(string clusterDir, long generationId)
|
||||
{
|
||||
var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
|
||||
var tmpPath = pointerPath + ".tmp";
|
||||
File.WriteAllText(tmpPath, generationId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
if (File.Exists(pointerPath))
|
||||
File.Replace(tmpPath, pointerPath, destinationBackupFileName: null);
|
||||
else
|
||||
File.Move(tmpPath, pointerPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sealed cache is unreachable — caller must fail closed.</summary>
|
||||
public sealed class GenerationCacheUnavailableException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the GenerationCacheUnavailableException class.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public GenerationCacheUnavailableException(string message) : base(message) { }
|
||||
/// <summary>Initializes a new instance of the GenerationCacheUnavailableException class with an inner exception.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="inner">The inner exception.</param>
|
||||
public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained snapshot of one generation — enough to rebuild the address space on a node
|
||||
/// that has lost DB connectivity. The payload is the JSON-serialized <c>sp_GetGenerationContent</c>
|
||||
/// result; the local cache doesn't inspect the shape, it just round-trips bytes.
|
||||
/// </summary>
|
||||
public sealed class GenerationSnapshot
|
||||
{
|
||||
/// <summary>Gets or sets the auto-generated LiteDB ID.</summary>
|
||||
public int Id { get; set; } // LiteDB auto-ID
|
||||
/// <summary>Gets or sets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
/// <summary>Gets or sets the generation identifier.</summary>
|
||||
public required long GenerationId { get; set; }
|
||||
/// <summary>Gets or sets the time this snapshot was cached.</summary>
|
||||
public required DateTime CachedAt { get; set; }
|
||||
/// <summary>Gets or sets the JSON-serialized payload content.</summary>
|
||||
public required string PayloadJson { get; set; }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
|
||||
/// address space when the central DB is unreachable (degraded-but-running).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Concurrency contract:</b> implementations must serialize writes — specifically,
|
||||
/// <see cref="PutAsync"/> for the same <c>(ClusterId, GenerationId)</c> from concurrent
|
||||
/// callers must not produce duplicate rows. Reads may run concurrently with reads and writes.
|
||||
/// The <see cref="LiteDbConfigCache"/> implementation enforces this via an instance-level
|
||||
/// <see cref="SemaphoreSlim"/> around the find-then-insert/update window.</para>
|
||||
/// </remarks>
|
||||
public interface ILocalConfigCache
|
||||
{
|
||||
/// <summary>Retrieves the most recent generation snapshot for the specified cluster.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The most recent generation snapshot, or null if none exists.</returns>
|
||||
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
|
||||
/// <summary>Stores a generation snapshot in the local cache.</summary>
|
||||
/// <param name="snapshot">The generation snapshot to store.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||
/// <summary>Removes old generations, keeping only the most recent N.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="keepLatest">The number of latest generations to keep.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
|
||||
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
|
||||
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
|
||||
/// delete and re-fetch from the central DB.
|
||||
/// </summary>
|
||||
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
{
|
||||
private const string CollectionName = "generations";
|
||||
|
||||
// LiteDB's default BsonMapper.Global is a process-wide singleton whose per-type member
|
||||
// registration is lazy and NOT thread-safe across concurrently-constructed LiteDatabase
|
||||
// instances. When several caches (this one + GenerationSealedCache) initialise in parallel
|
||||
// the global mapper races, surfacing as "Member ClusterId not found on BsonMapper" or a
|
||||
// bogus "duplicate key _id = 0" (the int auto-id mapping was lost so Insert writes a literal
|
||||
// 0 twice). Give each database a private, pre-registered mapper so member
|
||||
// resolution happens once, single-threaded, at construction and never touches the global.
|
||||
private static BsonMapper BuildMapper()
|
||||
{
|
||||
var mapper = new BsonMapper();
|
||||
mapper.Entity<GenerationSnapshot>();
|
||||
return mapper;
|
||||
}
|
||||
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<GenerationSnapshot> _col;
|
||||
// PutAsync is a find-then-insert/update; without serialization, two concurrent puts for the
|
||||
// same (ClusterId, GenerationId) can both observe `existing is null` and both Insert,
|
||||
// producing duplicate rows. Serialize writes through this semaphore so
|
||||
// the read-modify-write block is atomic for a given instance. LiteDB itself only locks the
|
||||
// page-level write, not the find-then-insert window.
|
||||
private readonly SemaphoreSlim _writeGate = new(initialCount: 1, maxCount: 1);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LiteDbConfigCache"/> class.</summary>
|
||||
/// <param name="dbPath">Path to the LiteDB database file.</param>
|
||||
public LiteDbConfigCache(string dbPath)
|
||||
{
|
||||
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
|
||||
// the header and "recover"), so we force a write + read probe to fail fast on real corruption.
|
||||
try
|
||||
{
|
||||
_db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true }, BuildMapper());
|
||||
_col = _db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
_col.EnsureIndex(s => s.ClusterId);
|
||||
_col.EnsureIndex(s => s.GenerationId);
|
||||
_ = _col.Count();
|
||||
}
|
||||
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||
or NotSupportedException or UnauthorizedAccessException
|
||||
or ArgumentOutOfRangeException or FormatException)
|
||||
{
|
||||
_db?.Dispose();
|
||||
throw new LocalConfigCacheCorruptException(
|
||||
$"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var snapshot = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// Serialize the find-then-insert/update so concurrent callers do not observe a stale
|
||||
// `existing is null` and both Insert. LiteDB's per-call lock is not enough — the
|
||||
// read and the write are independent calls.
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// upsert by (ClusterId, GenerationId) — replace in place if already cached
|
||||
var existing = _col
|
||||
.Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing is null)
|
||||
_col.Insert(snapshot);
|
||||
else
|
||||
{
|
||||
snapshot.Id = existing.Id;
|
||||
_col.Update(snapshot);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var doomed = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.Skip(keepLatest)
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in doomed)
|
||||
_col.Delete(id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources used by the cache.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_writeGate.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LocalConfigCacheCorruptException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
@@ -1,140 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Polly.Timeout;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a central-DB fetch function with Phase 6.1 Stream D.2 resilience:
|
||||
/// <b>timeout 2 s → retry 3× jittered → fallback to sealed cache</b>. Maintains the
|
||||
/// <see cref="StaleConfigFlag"/> — fresh on central-DB success, stale on cache fallback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Read-path only per plan. The write path (draft save, publish) bypasses this
|
||||
/// wrapper entirely and fails hard on DB outage so inconsistent writes never land.</para>
|
||||
///
|
||||
/// <para>Fallback is triggered by <b>any exception</b> the fetch raises (central-DB
|
||||
/// unreachable, SqlException, timeout). If the sealed cache also fails (no pointer,
|
||||
/// corrupt file, etc.), <see cref="GenerationCacheUnavailableException"/> surfaces — caller
|
||||
/// must fail the current request (InitializeAsync for a driver, etc.).</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilientConfigReader
|
||||
{
|
||||
private readonly GenerationSealedCache _cache;
|
||||
private readonly StaleConfigFlag _staleFlag;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientConfigReader> _logger;
|
||||
|
||||
/// <summary>Initializes a resilient config reader with the given cache and options.</summary>
|
||||
/// <param name="cache">The sealed cache for fallback.</param>
|
||||
/// <param name="staleFlag">The stale config flag to manage.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="timeout">The timeout for central fetch (default 2s).</param>
|
||||
/// <param name="retryCount">The number of retries (default 3).</param>
|
||||
public ResilientConfigReader(
|
||||
GenerationSealedCache cache,
|
||||
StaleConfigFlag staleFlag,
|
||||
ILogger<ResilientConfigReader> logger,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 3)
|
||||
{
|
||||
_cache = cache;
|
||||
_staleFlag = staleFlag;
|
||||
_logger = logger;
|
||||
var builder = new ResiliencePipelineBuilder()
|
||||
.AddTimeout(new TimeoutStrategyOptions { Timeout = timeout ?? TimeSpan.FromSeconds(2) });
|
||||
|
||||
if (retryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = retryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(1),
|
||||
// Handle ALL exceptions including OperationCanceledException. A SQL command-level
|
||||
// timeout surfaces as TaskCanceledException (derives from OperationCanceledException)
|
||||
// when the caller's token is NOT cancelled, and must be retried just like any other
|
||||
// transient error. Polly itself checks the cancellation token between retries and
|
||||
// stops with OperationCanceledException on genuine caller cancellation regardless of
|
||||
// this predicate.
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(),
|
||||
});
|
||||
}
|
||||
|
||||
_pipeline = builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts connection-string fragments (Password, User Id, Pwd, etc.)
|
||||
/// that a caller's exception message could carry. Conservative regex pass — anything
|
||||
/// matching <c>Key=Value</c> with a known credential key gets its value replaced.
|
||||
/// </summary>
|
||||
private static readonly Regex SecretsRegex = new(
|
||||
@"(?ix)\b(Password|Pwd|User\s*Id|Uid|AccessToken|Authorization|Api[-_]?Key)\s*=\s*[^;,)\s]*",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Redacts sensitive credential information from a message.</summary>
|
||||
/// <param name="message">The message to scrub.</param>
|
||||
/// <returns>The message with redacted credentials.</returns>
|
||||
internal static string ScrubSecrets(string? message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return message ?? string.Empty;
|
||||
// Replace the entire matched fragment (key + value) with a redaction marker so the
|
||||
// key name itself doesn't leak — log scrapers grep for "Password=" too.
|
||||
return SecretsRegex.Replace(message, "[redacted credential]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a central fetch through the resilience pipeline. On full failure
|
||||
/// (post-retry), reads the sealed cache and extracts the requested shape.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of configuration to read.</typeparam>
|
||||
/// <param name="clusterId">The cluster ID to fetch for.</param>
|
||||
/// <param name="centralFetch">Function to fetch from central DB.</param>
|
||||
/// <param name="fromSnapshot">Function to extract the config from a snapshot.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The configuration of type T.</returns>
|
||||
public async ValueTask<T> ReadAsync<T>(
|
||||
string clusterId,
|
||||
Func<CancellationToken, ValueTask<T>> centralFetch,
|
||||
Func<GenerationSnapshot, T> fromSnapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(centralFetch);
|
||||
ArgumentNullException.ThrowIfNull(fromSnapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _pipeline.ExecuteAsync(centralFetch, cancellationToken).ConfigureAwait(false);
|
||||
_staleFlag.MarkFresh();
|
||||
return result;
|
||||
}
|
||||
// Catch all exceptions that are NOT genuine caller cancellations. A SQL command-level
|
||||
// timeout surfaces as TaskCanceledException (derives from OperationCanceledException)
|
||||
// but the caller's token is NOT cancelled — we must fall back to the sealed cache for
|
||||
// that case, not propagate. Only rethrow if the caller actually requested cancellation.
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Do NOT pass the raw exception object — it carries the stack
|
||||
// and inner-exception chain, and SqlException/wrapping delegates can surface
|
||||
// connection-string fragments (Password=…, User Id=…) embedded in messages.
|
||||
// Log only the exception type and a scrubbed message so secrets stay out of logs.
|
||||
_logger.LogWarning(
|
||||
"Central-DB read failed after retries ({ExceptionType}: {SanitizedMessage}); falling back to sealed cache for cluster {ClusterId}",
|
||||
ex.GetType().Name,
|
||||
ScrubSecrets(ex.Message),
|
||||
clusterId);
|
||||
// GenerationCacheUnavailableException surfaces intentionally — fails the caller's
|
||||
// operation. StaleConfigFlag stays unchanged; the flag only flips when we actually
|
||||
// served a cache snapshot.
|
||||
var snapshot = await _cache.ReadCurrentAsync(clusterId, cancellationToken).ConfigureAwait(false);
|
||||
_staleFlag.MarkStale();
|
||||
return fromSnapshot(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <c>UsingStaleConfig</c> signal per Phase 6.1 Stream D.3. Flips true whenever
|
||||
/// a read falls back to a sealed cache snapshot; flips false on the next successful central-DB
|
||||
/// round-trip. Surfaced on <c>/healthz</c> body and on the Admin <c>/hosts</c> page.
|
||||
/// </summary>
|
||||
public sealed class StaleConfigFlag
|
||||
{
|
||||
private int _stale;
|
||||
|
||||
/// <summary>True when the last config read was served from the sealed cache, not the central DB.</summary>
|
||||
public bool IsStale => Volatile.Read(ref _stale) != 0;
|
||||
|
||||
/// <summary>Mark the current config as stale (a read fell back to the cache).</summary>
|
||||
public void MarkStale() => Volatile.Write(ref _stale, 1);
|
||||
|
||||
/// <summary>Mark the current config as fresh (a central-DB read succeeded).</summary>
|
||||
public void MarkFresh() => Volatile.Write(ref _stale, 0);
|
||||
}
|
||||
@@ -10,10 +10,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
/// Phase 6.2 compliance check on control/data-plane separation).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per Phase 6.2 Stream A.2 this service is expected to run behind the Phase 6.1
|
||||
/// <c>ResilientConfigReader</c> pipeline (timeout → retry → fallback-to-cache) so a
|
||||
/// transient DB outage during sign-in falls back to the sealed snapshot rather than
|
||||
/// denying every login.
|
||||
/// <para>
|
||||
/// This service has no local-cache fallback: a DB outage during sign-in denies logins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The Phase 6.1 <c>ResilientConfigReader</c> pipeline (timeout → retry →
|
||||
/// fallback-to-sealed-snapshot) this was once expected to run behind was never wired to
|
||||
/// anything and was deleted along with the rest of the dormant LiteDB local cache, which
|
||||
/// <c>ZB.MOM.WW.LocalDb</c> supersedes. LocalDb caches the deployed-configuration artifact
|
||||
/// for driver-role nodes; it does not currently cover admin-plane reads like this one.
|
||||
/// Reviving the fallback means adding an admin-side cache on LocalDb, not restoring the
|
||||
/// old pipeline.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ILdapGroupRoleMappingService
|
||||
{
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
<PackageReference Include="LiteDB"/>
|
||||
<PackageReference Include="Polly.Core"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -377,4 +377,9 @@ public enum EmissionKind
|
||||
Enabled,
|
||||
Disabled,
|
||||
CommentAdded,
|
||||
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
||||
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||
/// state change and must not materialize or historize a condition).</summary>
|
||||
QualityChanged,
|
||||
}
|
||||
|
||||
@@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
||||
/// with no accompanying Part 9 state transition drives a standalone
|
||||
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
||||
/// transition — so quality can't ride one, exactly like a comms-lost native driver). Only ever
|
||||
/// mutated under <c>_evalGate</c>; cleared alongside <see cref="_alarms"/> on load/dispose.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, int> _lastQualityBucketByAlarmId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||
@@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// have changed (different Inputs, different Logger), so any reuse would be
|
||||
// unsafe.
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||
// recompile this one. Skipping this is what made the earlier fix a
|
||||
// no-op in production.
|
||||
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||
if (result.Emission != EmissionKind.None)
|
||||
pending = BuildEmission(state, result.State, result.Emission);
|
||||
pending = BuildEmission(state, result.State, result.Emission, LastWorstStatus(alarmId));
|
||||
else if (result.NoOpReason is { } reason)
|
||||
{
|
||||
// The Part9StateMachine remarks promise a diagnostic log line for
|
||||
@@ -513,13 +525,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
||||
|
||||
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
||||
// transition is delivered out of band as a QualityChanged emission (see below).
|
||||
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
||||
var qualityBucketChanged = TrackQualityBucket(state.Definition.AlarmId, worstStatus);
|
||||
|
||||
// Cold-start guard — skip the predicate when any referenced upstream tag has no
|
||||
// cached value yet (the upstream subscription hasn't delivered its first push).
|
||||
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
|
||||
// every tick until the cache fills, spamming the log with identical stack traces.
|
||||
// Bad quality is treated the same: the input isn't available at the predicate's
|
||||
// expected type, so the only defensible move is to hold the prior condition state.
|
||||
if (!AreInputsReady(scratch.ReadCache)) return seed;
|
||||
if (!AreInputsReady(scratch.ReadCache))
|
||||
{
|
||||
// The condition is frozen (can't trust its state), but its source quality just changed
|
||||
// bucket — annotate it out of band so a comms-lost / Bad-input scripted condition reports
|
||||
// Bad, mirroring the native OT path.
|
||||
if (qualityBucketChanged)
|
||||
pendingEmissions.Add(BuildQualityEmission(state, seed, worstStatus));
|
||||
return seed;
|
||||
}
|
||||
|
||||
var context = scratch.Context;
|
||||
|
||||
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
}
|
||||
|
||||
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
||||
if (result.Emission != EmissionKind.None)
|
||||
var transition = result.Emission != EmissionKind.None
|
||||
? BuildEmission(state, result.State, result.Emission, worstStatus)
|
||||
: null;
|
||||
if (transition is not null)
|
||||
{
|
||||
var evt = BuildEmission(state, result.State, result.Emission);
|
||||
if (evt is not null) pendingEmissions.Add(evt);
|
||||
// A real transition carries the current worst quality so the projected full-snapshot
|
||||
// write doesn't clobber quality back to Good (e.g. a transition while an input is Uncertain).
|
||||
pendingEmissions.Add(transition);
|
||||
}
|
||||
else if (qualityBucketChanged)
|
||||
{
|
||||
// No transition (or a Suppressed one) but the quality bucket moved — annotate out of band.
|
||||
pendingEmissions.Add(BuildQualityEmission(state, result.State, worstStatus));
|
||||
}
|
||||
return result.State;
|
||||
}
|
||||
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||
/// released.
|
||||
/// </summary>
|
||||
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||
private ScriptedAlarmEvent? BuildEmission(
|
||||
AlarmState state, AlarmConditionState condition, EmissionKind kind, uint worstInputStatus)
|
||||
{
|
||||
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||
// but the state record still advanced so startup recovery reflects reality.
|
||||
@@ -629,9 +665,89 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// Carry the per-alarm durable-historization opt-out through to subscribers. The historian
|
||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||
// unaffected (it is not gated on this flag).
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva);
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||
// #478 — the worst input quality at evaluation time rides the transition so the projected
|
||||
// full snapshot keeps quality consistent (no clobber-to-Good).
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
||||
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
||||
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||
/// no <c>/alerts</c> row, no historian write); the <see cref="IAlarmSource"/> fan-out skips it.
|
||||
/// </summary>
|
||||
private ScriptedAlarmEvent BuildQualityEmission(
|
||||
AlarmState state, AlarmConditionState condition, uint worstInputStatus)
|
||||
=> new(
|
||||
AlarmId: state.Definition.AlarmId,
|
||||
EquipmentPath: state.Definition.EquipmentPath,
|
||||
AlarmName: state.Definition.AlarmName,
|
||||
Kind: state.Definition.Kind,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: condition,
|
||||
Emission: EmissionKind.QualityChanged,
|
||||
TimestampUtc: _clock(),
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
|
||||
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
|
||||
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
|
||||
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
|
||||
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
|
||||
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
|
||||
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
|
||||
/// cache ⇒ Good (0).</summary>
|
||||
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
uint worst = 0u;
|
||||
var worstSeverity = 0u;
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
|
||||
var status = kv.Value.StatusCode;
|
||||
var severity = status >> 30;
|
||||
if (severity > worstSeverity)
|
||||
{
|
||||
worstSeverity = severity;
|
||||
worst = status;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
|
||||
/// (0 = Good, 1 = Uncertain, 2 = Bad) changed from the last observed value. Only called under
|
||||
/// <c>_evalGate</c>.</summary>
|
||||
private bool TrackQualityBucket(string alarmId, uint worstStatus)
|
||||
{
|
||||
var bucket = QualityBucket(worstStatus);
|
||||
var prior = _lastQualityBucketByAlarmId.TryGetValue(alarmId, out var b) ? b : 0; // default Good
|
||||
_lastQualityBucketByAlarmId[alarmId] = bucket;
|
||||
return bucket != prior;
|
||||
}
|
||||
|
||||
/// <summary>Collapse an OPC UA StatusCode's 2 severity bits (00/01/10/11) to a 3-state quality bucket
|
||||
/// (0 = Good, 1 = Uncertain, 2 = Bad).</summary>
|
||||
private static int QualityBucket(uint statusCode)
|
||||
{
|
||||
var severity = statusCode >> 30;
|
||||
return severity >= 2 ? 2 : (int)severity;
|
||||
}
|
||||
|
||||
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
||||
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
||||
private uint LastWorstStatus(string alarmId)
|
||||
=> (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch
|
||||
{
|
||||
2 => 0x80000000u, // Bad
|
||||
1 => 0x40000000u, // Uncertain
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
||||
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
||||
@@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarms[id] = state with { Condition = result.State };
|
||||
if (result.Emission != EmissionKind.None)
|
||||
{
|
||||
var evt = BuildEmission(state, result.State, result.Emission);
|
||||
var evt = BuildEmission(state, result.State, result.Emission, LastWorstStatus(id));
|
||||
if (evt is not null) pending.Add(evt);
|
||||
}
|
||||
}
|
||||
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
|
||||
EmissionKind Emission,
|
||||
DateTime TimestampUtc,
|
||||
string? Comment = null,
|
||||
bool HistorizeToAveva = true);
|
||||
bool HistorizeToAveva = true,
|
||||
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint
|
||||
// (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
|
||||
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
||||
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
||||
if (evt.Emission == EmissionKind.QualityChanged) return;
|
||||
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
{
|
||||
if (!Matches(sub, evt)) continue;
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
|
||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
};
|
||||
|
||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
|
||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
||||
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
|
||||
{
|
||||
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
|
||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||
|
||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
||||
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||
// an AdminUI cancel still wins early.
|
||||
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
|
||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
|
||||
/// object initializer (you can't await inside one).
|
||||
/// </summary>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||
/// supported, in priority order:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||
/// no startup warning.</item>
|
||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||
/// forms supported, evaluated in order:
|
||||
/// <list type="number">
|
||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
||||
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
||||
/// doesn't emit a warning; production should never use this arm.</item>
|
||||
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||
/// throws rather than falling through to the literal arm — the production path
|
||||
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||
/// <item>Anything else — used as the literal API key for back-compat with
|
||||
/// configs that pre-date this resolver. When a logger is supplied the
|
||||
/// resolver emits a startup warning so an operator who accidentally
|
||||
/// committed a cleartext key sees it.</item>
|
||||
/// </list>
|
||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
||||
/// A future PR can swap any of these arms for a different backing store without
|
||||
/// changing the call site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
public static class GalaxySecretRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
||||
/// opt-in path that doesn't warn.
|
||||
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
|
||||
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||
/// <returns>The resolved API-key string.</returns>
|
||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
||||
public static async Task<string> ResolveApiKeyAsync(
|
||||
string secretRef,
|
||||
ISecretResolver resolver,
|
||||
ILogger? logger = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
||||
return secretRef[4..];
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Production path: resolve the name through the shared encrypted secret store.
|
||||
// Fail-closed — an absent/tombstoned secret throws rather than falling through
|
||||
// to the literal arm (which would silently treat the ref string as the key).
|
||||
var name = secretRef["secret:".Length..];
|
||||
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||
}
|
||||
|
||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||
// API key — but emit a warning so an operator who accidentally committed a
|
||||
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
||||
|
||||
+1
@@ -13,5 +13,6 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
private readonly ILogger<GalaxyDriver> _logger;
|
||||
|
||||
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
||||
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
||||
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
||||
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
|
||||
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
||||
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ISecretResolver secretResolver,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options,
|
||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||
alarmAcknowledger: null, alarmFeed: null, logger)
|
||||
alarmAcknowledger: null, alarmFeed: null, logger,
|
||||
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
|
||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
||||
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
||||
/// don't exercise a <c>secret:</c> ref need not supply one.
|
||||
/// </param>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
||||
IGalaxySubscriber? subscriber = null,
|
||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||
IGalaxyAlarmFeed? alarmFeed = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
ILogger<GalaxyDriver>? logger = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
? driverInstanceId
|
||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
||||
_driverInstanceId);
|
||||
}
|
||||
|
||||
StartDeployWatcher();
|
||||
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
||||
/// </summary>
|
||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ownedMxSession is null) return;
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
||||
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
||||
// warning rather than silently shipping the key. The resolver lives in
|
||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
||||
// AdminUI browser share one implementation.
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||
// async and you can't await inside an initializer. Pass the logger so the
|
||||
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private void StartDeployWatcher()
|
||||
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Repository.WatchDeployEvents) return;
|
||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||
|
||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
||||
// rather than overwriting the field and leaking the first instance.
|
||||
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||
// runs it reuses this client rather than overwriting the field and leaking the
|
||||
// first instance — the client-options build is now async (secret: arm).
|
||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||
BuildClientOptions(_options.Gateway));
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||
_deployWatcher = new DeployWatcher(source, _logger);
|
||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
||||
var source = _hierarchySource ??=
|
||||
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
||||
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
|
||||
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||
/// internal ctor.
|
||||
/// </summary>
|
||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
||||
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
||||
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
||||
// than always overwriting the field and leaking the first instance. Both paths
|
||||
// produce equivalent clients from the same options.
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
||||
// produce equivalent clients from the same options. The client-options build is
|
||||
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
return new TracedGalaxyHierarchySource(
|
||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "GalaxyMxGateway";
|
||||
|
||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
||||
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
|
||||
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ISecretResolver secretResolver,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
|
||||
}
|
||||
|
||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
||||
/// <summary>
|
||||
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
|
||||
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
|
||||
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
|
||||
/// threads the DI resolver.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
|
||||
|
||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
||||
RawTags = dto.RawTags ?? [],
|
||||
};
|
||||
|
||||
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||
/// (absent). Used as the default for the internal test ctor and the parse-only
|
||||
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
|
||||
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
|
||||
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
|
||||
/// null object throws fail-closed (the secret is reported absent), which is the correct
|
||||
/// behaviour for a mis-wired deployment.
|
||||
/// </summary>
|
||||
internal sealed class NullSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <summary>The shared singleton instance.</summary>
|
||||
public static readonly NullSecretResolver Instance = new();
|
||||
|
||||
private NullSecretResolver()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
+3
-1
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||
/// protections cover it.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriverOptions
|
||||
// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
|
||||
// credential-resolved copy with a `with` expression — every property keeps its init setter.
|
||||
public sealed record OpcUaClientDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
|
||||
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
|
||||
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
|
||||
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
|
||||
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
|
||||
/// <c>secret:</c> literal is never sent verbatim as a password.
|
||||
/// </summary>
|
||||
internal sealed class NullSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <summary>The shared singleton instance.</summary>
|
||||
public static readonly NullSecretResolver Instance = new();
|
||||
|
||||
private NullSecretResolver()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <param name="options">Driver configuration.</param>
|
||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||||
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||||
/// secret absent) for the test/parse-only path; the production factory threads the
|
||||
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||||
/// </param>
|
||||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||
ILogger<OpcUaClientDriver>? logger = null)
|
||||
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly string _driverInstanceId;
|
||||
// ---- IAlarmSource state ----
|
||||
|
||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var candidates = ResolveEndpointCandidates(_options);
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||
// are consumed. _options stays the raw config (with secret: refs) — only this
|
||||
// connect-scoped local carries plaintext, and only for the duration of the connect.
|
||||
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
||||
// Both credential consumers — the Username password below and the Certificate password
|
||||
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||
// single resolve covers both paths.
|
||||
var resolvedOptions = await OpcUaClientSecretResolution
|
||||
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var identity = BuildUserIdentity(resolvedOptions);
|
||||
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
|
||||
+23
-5
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
||||
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
/// <param name="secretResolver">
|
||||
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
|
||||
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
|
||||
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
|
||||
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
|
||||
/// </param>
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
var resolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
|
||||
}
|
||||
|
||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
|
||||
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
|
||||
/// </param>
|
||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||
public static OpcUaClientDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
||||
return new OpcUaClientDriver(
|
||||
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
|
||||
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
|
||||
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
|
||||
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
|
||||
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
|
||||
// credentials are actually consumed.
|
||||
OpcUaClientDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
|
||||
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
|
||||
/// session-open path consumes them — retiring the cleartext password-in-DB model for
|
||||
/// production.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
|
||||
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
|
||||
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
|
||||
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
|
||||
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
|
||||
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
|
||||
/// which would otherwise be sent verbatim to the remote server as the password.
|
||||
/// </remarks>
|
||||
internal static class OpcUaClientSecretResolution
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
|
||||
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
|
||||
/// returned unchanged.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||
/// <param name="resolver">The shared secret resolver.</param>
|
||||
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
@* No AdditionalAssemblies: the Secrets.Ui RCL's routable page is NOT routed directly in this
|
||||
host — Pages/Secrets.razor wraps it so the route can carry this host's per-page
|
||||
@rendermode (lmxopcua#483); registering the RCL routes too would make /admin/secrets
|
||||
ambiguous. *@
|
||||
<Routes />
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<ThemeScripts />
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Scripting" Key="scripting">
|
||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||
|
||||
@@ -64,7 +64,22 @@ else
|
||||
<tr>
|
||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
||||
<td><span class="mono small">@e.EquipmentPath</span></td>
|
||||
<td>
|
||||
<span class="mono small">@e.EquipmentPath</span>
|
||||
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
|
||||
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
|
||||
when the producer populated it (native alarms whose raw tag ≥1 equipment
|
||||
references); scripted alarms + unreferenced native alarms leave it empty. *@
|
||||
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
|
||||
{
|
||||
<div class="text-muted small" title="Referencing equipment">
|
||||
@foreach (var p in refs)
|
||||
{
|
||||
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
||||
<td class="num">@e.Severity</td>
|
||||
<td>@e.User</td>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/admin/secrets"
|
||||
@attribute [Authorize(Policy = ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.Secrets.Ui
|
||||
|
||||
@* Host-side wrapper for the ZB.MOM.WW.Secrets.Ui secrets page (lmxopcua#483).
|
||||
|
||||
This host's <Routes/> is deliberately static (cookie SignInAsync needs SSR), so every
|
||||
interactive page opts in per-page with @rendermode — a directive the RCL's own routable
|
||||
SecretsPage cannot carry, because the other three family hosts render it under a globally
|
||||
interactive router where a nested render mode throws. Routing straight to the RCL page
|
||||
therefore produced a statically-rendered, dead page here: no circuit, @onclick inert.
|
||||
|
||||
So this wrapper owns the /admin/secrets route in THIS host (the RCL assembly is no longer
|
||||
passed to either AdditionalAssemblies registration — doing both would make the route
|
||||
ambiguous), carries the standard per-page render mode, re-states the RCL page's own
|
||||
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
||||
which is now this one), and renders the RCL page as an ordinary child component. *@
|
||||
|
||||
<SecretsPage />
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
using ZB.MOM.WW.Secrets.Ui;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
|
||||
@@ -28,6 +30,12 @@ public static class EndpointRouteBuilderExtensions
|
||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||
// UseAuthentication() — see Program.cs.
|
||||
// The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
|
||||
// this host's router is static with per-page @rendermode opt-in, so routing straight to
|
||||
// the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
|
||||
// Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL
|
||||
// page with the render mode); registering the RCL routes too would make the route
|
||||
// ambiguous.
|
||||
app.MapRazorComponents<TApp>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
return app;
|
||||
@@ -43,6 +51,15 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
services.AddOtOpcUaDriverStatusServices();
|
||||
|
||||
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
|
||||
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
|
||||
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
|
||||
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
|
||||
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
|
||||
// composition layer that already references the RCL) rather than in the core Security lib.
|
||||
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||
|
||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// One HTTP endpoint the host was asked to serve, parsed out of the configured URL list so it
|
||||
/// can be re-bound explicitly.
|
||||
/// </summary>
|
||||
/// <param name="Host">The host component as written — <c>+</c>, <c>*</c>, a hostname, or an IP.</param>
|
||||
/// <param name="Port">The port.</param>
|
||||
/// <param name="IsSecure">True when the URL used the <c>https</c> scheme.</param>
|
||||
public sealed record KestrelHttpBinding(string Host, int Port, bool IsSecure)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a semicolon-separated URL list (the <c>ASPNETCORE_URLS</c> / <c>urls</c> format)
|
||||
/// into bindings. Unparseable entries are skipped rather than throwing — a malformed URL
|
||||
/// must not take the process down at startup.
|
||||
/// </summary>
|
||||
/// <param name="urls">The configured URL list, or null/empty when none is configured.</param>
|
||||
/// <returns>The parsed bindings, in the order given; empty when nothing is configured.</returns>
|
||||
public static IReadOnlyList<KestrelHttpBinding> Parse(string? urls)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(urls))
|
||||
return [];
|
||||
|
||||
var result = new List<KestrelHttpBinding>();
|
||||
foreach (var raw in urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
// Uri cannot parse the "+"/"*" wildcard hosts Kestrel accepts, so substitute a
|
||||
// placeholder host purely to get scheme/port out, then keep the original host token.
|
||||
var isWildcard = raw.Contains("://+", StringComparison.Ordinal)
|
||||
|| raw.Contains("://*", StringComparison.Ordinal);
|
||||
var probe = isWildcard
|
||||
? raw.Replace("://+", "://placeholder", StringComparison.Ordinal)
|
||||
.Replace("://*", "://placeholder", StringComparison.Ordinal)
|
||||
: raw;
|
||||
|
||||
if (!Uri.TryCreate(probe, UriKind.Absolute, out var uri))
|
||||
continue;
|
||||
|
||||
var host = isWildcard
|
||||
? raw.Contains("://+", StringComparison.Ordinal) ? "+" : "*"
|
||||
: uri.Host;
|
||||
|
||||
result.Add(new KestrelHttpBinding(host, uri.Port, uri.Scheme == Uri.UriSchemeHttps));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the bare-port list format of <c>ASPNETCORE_HTTP_PORTS</c> / <c>HTTP_PORTS</c> (and
|
||||
/// the <c>HTTPS</c> variants) into wildcard (<c>+</c>, all-interfaces) bindings — the shape
|
||||
/// Kestrel itself gives those variables. Modern .NET base images (aspnet:8.0+) set
|
||||
/// <c>ASPNETCORE_HTTP_PORTS=8080</c> as the container default <b>instead of</b>
|
||||
/// <c>ASPNETCORE_URLS</c>, so a node that never sets <c>URLS</c> explicitly still has a real
|
||||
/// configured surface here that must be re-bound, not replaced with Kestrel's localhost:5000.
|
||||
/// </summary>
|
||||
/// <param name="ports">A <c>;</c>- or <c>,</c>-separated list of bare ports, or null/empty.</param>
|
||||
/// <param name="isSecure">True to mark the parsed bindings <c>https</c> (the <c>HTTPS_PORTS</c> vars).</param>
|
||||
/// <returns>One all-interfaces binding per parseable port; empty when nothing is configured.</returns>
|
||||
public static IReadOnlyList<KestrelHttpBinding> ParsePorts(string? ports, bool isSecure)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ports))
|
||||
return [];
|
||||
|
||||
var result = new List<KestrelHttpBinding>();
|
||||
foreach (var token in ports.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
// Skip a malformed port rather than throwing — a bad env var must not down startup.
|
||||
if (int.TryParse(token, out var port) && port is > 0 and <= 65535)
|
||||
result.Add(new KestrelHttpBinding("+", port, isSecure));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies this binding to Kestrel, choosing the narrowest listen call the host component
|
||||
/// allows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A hostname that is neither a literal IP nor <c>localhost</c> cannot be reliably mapped to
|
||||
/// a local interface, so it falls back to <see cref="KestrelServerOptions.ListenAnyIP"/> —
|
||||
/// the safe superset. Narrowing it and guessing wrong would silently stop serving.
|
||||
/// </remarks>
|
||||
/// <param name="options">The Kestrel options being configured.</param>
|
||||
public void Apply(KestrelServerOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (Host is "+" or "*")
|
||||
options.ListenAnyIP(Port);
|
||||
else if (IPAddress.TryParse(Host, out var address))
|
||||
options.Listen(address, Port);
|
||||
else if (string.Equals(Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
options.ListenLocalhost(Port);
|
||||
else
|
||||
options.ListenAnyIP(Port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Single registration point for the node-local LocalDb subsystem: the embedded SQLite store
|
||||
/// that caches the deployed-configuration artifact, plus the optional gRPC replication that
|
||||
/// mirrors it to the node's redundant pair peer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Exists as a named extension rather than inline <c>AddZbLocalDb</c> calls in
|
||||
/// <c>Program.cs</c> so the wiring is covered by a DI resolution test — <c>Program.cs</c> is
|
||||
/// top-level statements and cannot be exercised directly, which is exactly how a
|
||||
/// "registered but never resolvable" defect ships unnoticed. This family has shipped that
|
||||
/// defect three times (Secrets 0.2.0, Secrets 0.2.2, ScadaBridge #22).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Driver-role nodes only.</b> Admin-only nodes have no deployed configuration to cache,
|
||||
/// and registering here would impose the <c>LocalDb:Path</c>-required-or-no-boot constraint
|
||||
/// on them for nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Storage is unconditional; replication is default-OFF.</b> A node with no
|
||||
/// <c>PeerAddress</c> and no <c>SyncListenPort</c> is simply a fast local SQLite file —
|
||||
/// <c>AddZbLocalDbReplication</c> registers the engine but it stays idle. Replication is
|
||||
/// opt-in per pair because there is no production distribution story for the sync
|
||||
/// <c>ApiKey</c> yet (the same open question the Secrets KEK has).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDbRegistration
|
||||
{
|
||||
/// <summary>Configuration section holding <c>LocalDbOptions</c>.</summary>
|
||||
public const string LocalDbSectionPath = "LocalDb";
|
||||
|
||||
/// <summary>Configuration section holding <c>ReplicationOptions</c>.</summary>
|
||||
public const string ReplicationSectionPath = "LocalDb:Replication";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
|
||||
/// no listener is bound and the passive sync endpoint is not mapped.
|
||||
/// </summary>
|
||||
public const string SyncListenPortKey = "LocalDb:SyncListenPort";
|
||||
|
||||
/// <summary>
|
||||
/// The port the dedicated cleartext-HTTP/2 sync listener should bind, or <c>0</c> when the
|
||||
/// node should not listen at all. Defaults to <c>0</c> — absent configuration must mean
|
||||
/// "off".
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns>The configured sync port, or <c>0</c>.</returns>
|
||||
public static int SyncListenPort(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return configuration.GetValue(SyncListenPortKey, defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the local database (with the deployment-cache schema applied and registered for
|
||||
/// replication) and the replication engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="LocalDbSetup.OnReady"/> runs inside <c>AddZbLocalDb</c>'s singleton factory,
|
||||
/// before the first caller receives the <c>ILocalDb</c> — so every consumer is guaranteed
|
||||
/// to see the tables created and their capture triggers installed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AddZbLocalDbReplication</c> is called unconditionally rather than behind an
|
||||
/// <c>Enabled</c> flag because the library already models "off" as "no
|
||||
/// <c>PeerAddress</c>": the initiator background service idles and the passive endpoint
|
||||
/// is only reachable if <c>Program.cs</c> maps it, which it does only when
|
||||
/// <see cref="SyncListenPortKey"/> is set. Adding a second gate on top would give two
|
||||
/// ways to express the same thing and a way for them to disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaLocalDb(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddZbLocalDb(configuration, LocalDbSetup.OnReady);
|
||||
services.AddZbLocalDbReplication(configuration);
|
||||
|
||||
// The consumer-facing seam. WithOtOpcUaRuntimeActors resolves this optionally and threads it
|
||||
// into DriverHostActor; registering the store without this leaves the cache tables present,
|
||||
// replicating, and never written to.
|
||||
services.AddSingleton<IDeploymentArtifactCache, LocalDbDeploymentArtifactCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache
|
||||
/// tables and opts them into replication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Public rather than internal only so <c>LocalDbSetupTests</c> can drive the production
|
||||
/// callback directly. Initialising a test database from a hand-written copy of this schema
|
||||
/// would prove only that the test agrees with itself — the whole value of those tests is
|
||||
/// that they exercise <b>this</b> method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDbSetup
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises the local database: DDL first, then replication registration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>THE ORDER IS LOAD-BEARING: DDL → RegisterReplicated → writes.</b>
|
||||
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
|
||||
/// changes into the oplog. Any row written before that call is never captured, so it
|
||||
/// never reaches the peer — silently, and permanently, because nothing ever revisits
|
||||
/// history. Phase 1 writes nothing here; when Phase 2 adds its store-and-forward
|
||||
/// migrator, the migrator must run <i>after</i> both registrations for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="db">The freshly constructed local database.</param>
|
||||
public static void OnReady(ILocalDb db)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
|
||||
// CreateConnection() hands back an already-open, pragma-configured connection carrying the
|
||||
// zb_hlc_next() UDF the capture triggers need. Calling Open() on it would throw.
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
DeploymentCacheSchema.Apply(connection);
|
||||
}
|
||||
|
||||
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
|
||||
db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gates the LocalDb passive sync endpoint. The replication library deliberately leaves inbound
|
||||
/// authentication to the host — its <c>LocalDbSyncService</c> verifies nothing — so without this
|
||||
/// interceptor anything that can reach a driver node's sync port could stream arbitrary rows
|
||||
/// into that node's deployment-artifact cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why that matters here specifically.</b> The cached artifact is what a driver node
|
||||
/// boots from when central SQL is unreachable. An attacker able to write it could choose
|
||||
/// the configuration a node comes up on during exactly the outage nobody is watching.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Scoped by method path.</b> Only calls under <c>/localdb_sync.v1.LocalDbSync/</c> are
|
||||
/// gated; every other method on the shared gRPC pipeline passes through untouched.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed.</b> With no <c>LocalDb:Replication:ApiKey</c> configured, NO sync stream
|
||||
/// is accepted, authenticated or not. That is the deliberate choice: the alternative —
|
||||
/// treating "no key" as "no auth required" — would silently expose the endpoint on exactly
|
||||
/// the default configuration every node ships with. An operator enabling replication must
|
||||
/// set the same key on both nodes of the pair, which is already required for the initiator
|
||||
/// to dial out (<c>SyncBackgroundService</c> sends <c>Authorization: Bearer <key></c>).
|
||||
/// A key typo therefore stops convergence outright rather than degrading to unauthenticated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> over UTF-8 bytes, so
|
||||
/// a wrong key cannot be recovered byte-by-byte from response timing. Length differences are
|
||||
/// unavoidably observable and are not sensitive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbSyncAuthInterceptor : Interceptor
|
||||
{
|
||||
private const string ServicePrefix = "/localdb_sync.v1.LocalDbSync/";
|
||||
private const string AuthorizationHeader = "authorization";
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
|
||||
private readonly IOptions<ReplicationOptions> _options;
|
||||
private readonly ILogger<LocalDbSyncAuthInterceptor> _logger;
|
||||
|
||||
/// <summary>Creates the interceptor.</summary>
|
||||
/// <param name="options">Replication options; <c>ApiKey</c> is the expected bearer token.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
public LocalDbSyncAuthInterceptor(
|
||||
IOptions<ReplicationOptions> options,
|
||||
ILogger<LocalDbSyncAuthInterceptor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, responseStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(requestStream, context);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
Authorize(context);
|
||||
return continuation(request, responseStream, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this
|
||||
/// is a sync call that does not carry the configured bearer token. Non-sync calls return
|
||||
/// immediately.
|
||||
/// </summary>
|
||||
private void Authorize(ServerCallContext context)
|
||||
{
|
||||
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var expected = _options.Value.ApiKey;
|
||||
if (string.IsNullOrEmpty(expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a LocalDb sync call to {Method}: no LocalDb:Replication:ApiKey is configured, " +
|
||||
"so the passive sync endpoint is closed. Configure the same key on both nodes of the pair.",
|
||||
context.Method);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"LocalDb sync is not accepting connections: no API key is configured on this node."));
|
||||
}
|
||||
|
||||
var presented = ExtractBearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a LocalDb sync call to {Method}: {Reason}.",
|
||||
context.Method,
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"LocalDb sync authentication failed."));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractBearerToken(Metadata headers)
|
||||
{
|
||||
// gRPC lowercases header keys on the wire; compare case-insensitively anyway so a
|
||||
// hand-built Metadata in a test behaves the same as a real request.
|
||||
foreach (var entry in headers)
|
||||
{
|
||||
if (!string.Equals(entry.Key, AuthorizationHeader, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var value = entry.Value;
|
||||
if (value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return value[BearerPrefix.Length..];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Forces this node's secret-replication actor into existence at startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The replication actor is created <b>lazily</b>, on the first resolution of
|
||||
/// <see cref="ISecretStore"/> — resolving the store builds <c>ReplicatingSecretStore</c>,
|
||||
/// which takes <c>ISecretReplicator</c>, whose factory touches
|
||||
/// <c>SecretReplicationActorProvider.ActorRef</c> and thereby spawns the actor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Laziness is correct for the library (the <c>ActorSystem</c> is usually registered after
|
||||
/// the replication call), but it makes participation in anti-entropy accidental: a node that
|
||||
/// happens never to read or write a secret — a plausible steady state for a driver-role node
|
||||
/// whose driver configs carry no <c>${secret:}</c> references — would never create the actor,
|
||||
/// never announce its manifest, and never converge. Nothing would fail; it would just
|
||||
/// silently not replicate. Resolving the store once at startup makes participation
|
||||
/// unconditional.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>History — this hook has met two library defects, both fixed.</b> Against 0.2.0 it was
|
||||
/// inert: the package never bound its own <c>ISecretReplicator</c> (TryAdd registration
|
||||
/// order), so this resolve built a <c>ReplicatingSecretStore</c> around a no-op sink and
|
||||
/// spawned no actor. Against 0.2.1 it was worse: the resolve <b>deadlocked the host at
|
||||
/// startup</b> — the package's DI graph had a circular singleton dependency, invisible to
|
||||
/// the container through factory lambdas (scadaproj#1). Fixed in 0.2.2 by deferring the
|
||||
/// cycle-closing invalidator edge;
|
||||
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>
|
||||
/// exercises this hook against a built provider on a real single-node cluster.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
|
||||
public sealed class SecretReplicationStarter(IServiceProvider services) : IHostedService
|
||||
{
|
||||
/// <summary>Resolves <see cref="ISecretStore"/>, which spawns the replication actor.</summary>
|
||||
/// <param name="cancellationToken">Unused — resolution is synchronous and non-blocking.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The resolution itself is the side effect; the instance is deliberately unused.
|
||||
_ = services.GetRequiredService<ISecretStore>();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>No-op: the actor's lifetime is the actor system's.</summary>
|
||||
/// <param name="cancellationToken">Unused.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Single registration point for the secrets subsystem on every OtOpcUa node, admin- and
|
||||
/// driver-role alike. Exists as a named extension rather than an inline <c>AddZbSecrets</c> call
|
||||
/// in <c>Program.cs</c> so the choice of <c>ISecretStore</c> implementation is covered by a DI
|
||||
/// resolution test — <c>Program.cs</c> is top-level statements and cannot be exercised directly,
|
||||
/// which is exactly how a "registered but never resolvable" wiring defect ships unnoticed.
|
||||
/// </summary>
|
||||
public static class SecretsRegistration
|
||||
{
|
||||
/// <summary>Configuration key gating cluster secret replication. Absent or false = off.</summary>
|
||||
public const string ReplicationEnabledKey = "Secrets:Replication:Enabled";
|
||||
|
||||
/// <summary>Configuration section holding <c>AkkaSecretsReplicationOptions</c>.</summary>
|
||||
public const string ReplicationSectionPath = "Secrets:Replication";
|
||||
|
||||
/// <summary>Configuration section holding the core secrets options.</summary>
|
||||
public const string SecretsSectionPath = "Secrets";
|
||||
|
||||
/// <summary>
|
||||
/// True when cluster secret replication is switched on in configuration. Defaults to false —
|
||||
/// absent configuration must mean "off".
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns><c>true</c> when replication is enabled.</returns>
|
||||
public static bool IsReplicationEnabled(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return configuration.GetValue(ReplicationEnabledKey, defaultValue: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the secrets subsystem. Replication is <b>opt-in</b>: unless
|
||||
/// <see cref="ReplicationEnabledKey"/> is true this is exactly the plain local SQLite wiring
|
||||
/// the host has always used.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gate is deliberately default-deny. This call decides which <c>ISecretStore</c> the
|
||||
/// container hands to <b>every</b> node, including driver-role nodes with no auth or
|
||||
/// AdminUI surface, where a bad store surfaces as drivers failing to open sessions rather
|
||||
/// than as a failing test. Enabling replication by default would change secret resolution
|
||||
/// across a production fleet with nobody having asked for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> internally, so the two
|
||||
/// branches are alternatives, never additive — calling both would double-register.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaSecrets(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
if (!IsReplicationEnabled(configuration))
|
||||
{
|
||||
services.AddZbSecrets(configuration, SecretsSectionPath);
|
||||
return services;
|
||||
}
|
||||
|
||||
services.AddZbSecretsAkkaReplication(configuration, SecretsSectionPath, ReplicationSectionPath);
|
||||
|
||||
// Anti-entropy participation must not hinge on this node happening to touch a secret — see
|
||||
// SecretReplicationStarter for why the library's lazy actor creation is insufficient here.
|
||||
services.AddSingleton<SecretReplicationStarter>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<SecretReplicationStarter>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
|
||||
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
|
||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||
Register(registry, loggerFactory, scriptRoot);
|
||||
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
|
||||
private static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
@@ -36,7 +41,20 @@ public static class HealthEndpoints
|
||||
"admin-leader",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active },
|
||||
args: "admin");
|
||||
args: "admin")
|
||||
// Registered unconditionally, not driver-gated. AddOtOpcUaHealth takes no role argument
|
||||
// and runs on every node; the check itself resolves ISyncStatus optionally and reports
|
||||
// Healthy when LocalDb is absent (admin-only graphs) or replication is default-OFF, so a
|
||||
// plain node is never degraded by it. A factory registration keeps this no-arg signature
|
||||
// while still reading ISyncStatus + options + the sync port from the container.
|
||||
.Add(new HealthCheckRegistration(
|
||||
"localdb-replication",
|
||||
sp => new LocalDbReplicationHealthCheck(
|
||||
sp.GetService<ISyncStatus>(),
|
||||
sp.GetRequiredService<IOptions<ReplicationOptions>>(),
|
||||
LocalDbRegistration.SyncListenPort(sp.GetRequiredService<IConfiguration>())),
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active }));
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision function for the LocalDb replication probe, factored out of
|
||||
/// <see cref="LocalDbReplicationHealthCheck"/> so the whole matrix is table-testable without
|
||||
/// standing up a replication engine.
|
||||
/// </summary>
|
||||
public static class LocalDbReplicationDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps the resolved replication facts to a health status.
|
||||
/// </summary>
|
||||
/// <param name="peerConfigured">
|
||||
/// True when this node participates in replication at all — it has a peer address to dial, or
|
||||
/// a sync listener bound. False means default-OFF, and default-OFF must never degrade a node.
|
||||
/// </param>
|
||||
/// <param name="connected">Whether a sync session is currently running.</param>
|
||||
/// <param name="oplogBacklog">
|
||||
/// The unacked oplog backlog, or <see langword="null"/> when it could not be read. Null is
|
||||
/// deliberately not treated as zero: a failed poll is "unknown", not "caught up".
|
||||
/// </param>
|
||||
/// <param name="degradedThreshold">
|
||||
/// Backlog at or above which a connected pair is judged to be falling behind.
|
||||
/// </param>
|
||||
/// <returns>The status plus a human-readable reason.</returns>
|
||||
public static (HealthStatus Status, string Description) Evaluate(
|
||||
bool peerConfigured, bool connected, long? oplogBacklog, long degradedThreshold)
|
||||
{
|
||||
if (!peerConfigured)
|
||||
return (HealthStatus.Healthy, "LocalDb replication is not configured on this node (default-OFF).");
|
||||
|
||||
if (!connected)
|
||||
return (HealthStatus.Degraded, "LocalDb replication is configured but no sync session is connected.");
|
||||
|
||||
if (oplogBacklog is null)
|
||||
return (HealthStatus.Degraded, "LocalDb replication is connected but its oplog backlog is unknown (the poll failed).");
|
||||
|
||||
if (oplogBacklog.Value >= degradedThreshold)
|
||||
return (HealthStatus.Degraded,
|
||||
$"LocalDb replication is connected but its oplog backlog ({oplogBacklog.Value}) is at or above the degraded threshold ({degradedThreshold}).");
|
||||
|
||||
return (HealthStatus.Healthy, $"LocalDb replication is connected; oplog backlog {oplogBacklog.Value}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the health of this node's LocalDb replication link. Registered unconditionally with
|
||||
/// the shared health pipeline; when LocalDb is not present at all (admin-only graphs) it reports
|
||||
/// <see cref="HealthStatus.Healthy"/> rather than degrading — matching the
|
||||
/// <c>ActiveNodeHealthCheck</c> precedent of "not applicable ⇒ Healthy".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A node running LocalDb with replication switched off (no peer, no listener) is also Healthy:
|
||||
/// that is the default posture for most of the fleet, and it must not look degraded. Only a node
|
||||
/// that is <i>meant</i> to be replicating and is not — or is connected but cannot confirm it is
|
||||
/// draining — is surfaced as Degraded. It never reports Unhealthy: a replication problem does not
|
||||
/// stop the node serving its address space, so it must not fail a readiness gate.
|
||||
/// </remarks>
|
||||
public sealed class LocalDbReplicationHealthCheck : IHealthCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Backlog at or above which a connected pair is reported Degraded. Well below the library's
|
||||
/// <c>MaxOplogRows</c> default (1,000,000, where a snapshot resync is forced), so the probe
|
||||
/// flags a pair that is falling behind long before the engine itself intervenes.
|
||||
/// </summary>
|
||||
public const long DefaultBacklogDegradedThreshold = 100_000;
|
||||
|
||||
private readonly ISyncStatus? _syncStatus;
|
||||
private readonly ReplicationOptions _options;
|
||||
private readonly int _syncListenPort;
|
||||
private readonly long _degradedThreshold;
|
||||
|
||||
/// <summary>Creates the health check.</summary>
|
||||
/// <param name="syncStatus">
|
||||
/// The replication status singleton, or <see langword="null"/> when the replication engine is
|
||||
/// not registered (admin-only nodes) — in which case the check is a Healthy no-op.
|
||||
/// </param>
|
||||
/// <param name="options">The bound replication options (peer address, etc.).</param>
|
||||
/// <param name="syncListenPort">
|
||||
/// The configured sync listener port; a value greater than zero counts as "replication
|
||||
/// configured" even when this node is the passive side with no peer address.
|
||||
/// </param>
|
||||
/// <param name="degradedThreshold">Backlog degraded threshold; defaults to <see cref="DefaultBacklogDegradedThreshold"/>.</param>
|
||||
public LocalDbReplicationHealthCheck(
|
||||
ISyncStatus? syncStatus,
|
||||
IOptions<ReplicationOptions> options,
|
||||
int syncListenPort,
|
||||
long degradedThreshold = DefaultBacklogDegradedThreshold)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_syncStatus = syncStatus;
|
||||
_options = options.Value;
|
||||
_syncListenPort = syncListenPort;
|
||||
_degradedThreshold = degradedThreshold;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// No replication engine registered at all → not applicable → Healthy. Admin-only nodes and
|
||||
// any graph that did not call AddOtOpcUaLocalDb land here.
|
||||
if (_syncStatus is null)
|
||||
return Task.FromResult(HealthCheckResult.Healthy(
|
||||
"LocalDb replication is not present on this node."));
|
||||
|
||||
var peerConfigured =
|
||||
!string.IsNullOrWhiteSpace(_options.PeerAddress) || _syncListenPort > 0;
|
||||
|
||||
var (status, description) = LocalDbReplicationDecision.Evaluate(
|
||||
peerConfigured, _syncStatus.Connected, _syncStatus.OplogBacklog, _degradedThreshold);
|
||||
|
||||
var result = status switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
_ => HealthCheckResult.Degraded(description),
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.Telemetry;
|
||||
|
||||
@@ -27,7 +28,11 @@ public static class ObservabilityExtensions
|
||||
return services.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "otopcua";
|
||||
o.Meters = [OtOpcUaTelemetry.MeterName];
|
||||
// o.Meters is a STRICT allowlist — ZbTelemetry adds only the named meters, with no
|
||||
// wildcard. The LocalDb replication engine publishes under its own meter name, so
|
||||
// without this entry its localdb.sync.* / localdb.oplog.depth series are silently absent
|
||||
// from /metrics on driver nodes (the exact omission a ScadaBridge live gate caught).
|
||||
o.Meters = [OtOpcUaTelemetry.MeterName, LocalDbMetrics.MeterName];
|
||||
o.ActivitySources = [OtOpcUaTelemetry.ActivitySourceName];
|
||||
if (Enum.TryParse<ZbExporter>(configuration["OtOpcUa:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||
o.Exporter = exporter;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
@@ -37,6 +39,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Configuration;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -72,6 +80,27 @@ if (roleSuffix is not null)
|
||||
builder.Configuration.AddCommandLine(args);
|
||||
}
|
||||
|
||||
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
|
||||
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
|
||||
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
|
||||
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
|
||||
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
|
||||
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
|
||||
// it also migrates the secrets SQLite store on startup.
|
||||
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
|
||||
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
|
||||
#pragma warning disable ASP0000
|
||||
await using (var secretsProvider = new ServiceCollection()
|
||||
.AddZbSecrets(builder.Configuration, "Secrets")
|
||||
.BuildServiceProvider())
|
||||
#pragma warning restore ASP0000
|
||||
{
|
||||
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
|
||||
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||
await new SecretReferenceExpander(resolver)
|
||||
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
|
||||
}
|
||||
|
||||
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
||||
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
||||
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
||||
@@ -147,6 +176,20 @@ if (hasDriver)
|
||||
builder.Configuration,
|
||||
(_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp));
|
||||
|
||||
// Node-local LocalDb: caches the deployed-configuration artifact so this node can boot from
|
||||
// its last-known-good config when central SQL is unreachable, and optionally replicates that
|
||||
// cache to its redundant pair peer. Driver-role only — admin-only nodes have nothing to cache,
|
||||
// and registering here would impose LocalDb:Path-required-or-no-boot on them for nothing.
|
||||
// Storage ships unconditionally; replication stays inert until LocalDb:Replication:PeerAddress
|
||||
// (initiator) / LocalDb:SyncListenPort (listener) are set. See LocalDbRegistration.
|
||||
builder.Services.AddOtOpcUaLocalDb(builder.Configuration);
|
||||
|
||||
// gRPC server plumbing for the passive sync endpoint mapped below. Registered only under
|
||||
// hasDriver so admin-only nodes expose no sync surface at all. The interceptor is the ONLY
|
||||
// inbound auth on that endpoint — the replication library's LocalDbSyncService verifies
|
||||
// nothing — and it fail-closes when no ApiKey is configured.
|
||||
builder.Services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());
|
||||
|
||||
// Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this
|
||||
// overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with
|
||||
// a read-only HistorianGateway-backed data source the node manager's HistoryRead overrides
|
||||
@@ -287,6 +330,14 @@ if (hasDriver)
|
||||
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||
{
|
||||
ab.WithOtOpcUaClusterBootstrap(sp);
|
||||
// Secret-replication wire protocol → its own serializer, merged only when replication is on so a
|
||||
// non-replicating node carries no serializer bindings for messages it will never see. Appended
|
||||
// (Akka.Hosting's fallback merge — the same mode WithOtOpcUaClusterBootstrap uses for the base
|
||||
// config) rather than a raw Config.WithFallback, which would fight the builder's own assembly.
|
||||
// Without this the protocol DTOs would round-trip on Akka's default JSON serializer, leaving the
|
||||
// serializer that carries secret ciphertext an inherited default rather than an explicit choice.
|
||||
if (SecretsRegistration.IsReplicationEnabled(builder.Configuration))
|
||||
ab.AddHocon(AkkaSecretsReplication.SerializationConfig, HoconAddMode.Append);
|
||||
if (hasAdmin)
|
||||
{
|
||||
ab.WithOtOpcUaControlPlaneSingletons();
|
||||
@@ -323,9 +374,99 @@ if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
|
||||
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
|
||||
builder.Services.AddOtOpcUaSecrets(builder.Configuration);
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// LocalDb sync listener (default-OFF).
|
||||
//
|
||||
// DANGER: any explicit Kestrel Listen* call makes Kestrel IGNORE ASPNETCORE_URLS/urls ENTIRELY
|
||||
// (it logs "Overriding address(es)"). The host has no ConfigureKestrel today and binds solely via
|
||||
// that configuration, so adding a listener naively would silently unbind the AdminUI + deploy API
|
||||
// behind Traefik. Everything the host was already asked to serve is therefore re-bound explicitly
|
||||
// in the same block.
|
||||
//
|
||||
// The listener is HTTP/2-ONLY on purpose: the sync client speaks prior-knowledge h2c, which a
|
||||
// cleartext Http1AndHttp2 endpoint cannot negotiate (there is no ALPN without TLS). Hence a
|
||||
// dedicated port rather than multiplexing onto the main one.
|
||||
//
|
||||
// When LocalDb:SyncListenPort is 0 (the default) none of this runs and URL binding is untouched.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
var syncListenPort = LocalDbRegistration.SyncListenPort(builder.Configuration);
|
||||
if (hasDriver && syncListenPort > 0)
|
||||
{
|
||||
// Mirror Kestrel's own source precedence: URLS wins; else the HTTP_PORTS/HTTPS_PORTS bare-port
|
||||
// vars; else Kestrel's localhost:5000 default. Missing the HTTP_PORTS leg is not academic — the
|
||||
// aspnet:8.0+ base images set ASPNETCORE_HTTP_PORTS=8080 as the CONTAINER default in place of
|
||||
// ASPNETCORE_URLS, so a driver node that never sets URLS is really serving :8080 on all
|
||||
// interfaces. Falling straight through to localhost:5000 would silently move its health/metrics
|
||||
// surface to loopback (found on the docker-dev live gate).
|
||||
var configuredUrls = builder.Configuration["urls"]
|
||||
?? builder.Configuration["ASPNETCORE_URLS"];
|
||||
var existingBindings = KestrelHttpBinding.Parse(configuredUrls);
|
||||
|
||||
if (existingBindings.Count == 0)
|
||||
{
|
||||
var httpPorts = builder.Configuration["ASPNETCORE_HTTP_PORTS"] ?? builder.Configuration["HTTP_PORTS"];
|
||||
var httpsPorts = builder.Configuration["ASPNETCORE_HTTPS_PORTS"] ?? builder.Configuration["HTTPS_PORTS"];
|
||||
existingBindings =
|
||||
[
|
||||
.. KestrelHttpBinding.ParsePorts(httpPorts, isSecure: false),
|
||||
.. KestrelHttpBinding.ParsePorts(httpsPorts, isSecure: true),
|
||||
];
|
||||
if (existingBindings.Count > 0)
|
||||
Log.Information(
|
||||
"LocalDb sync listener enabled; re-binding the HTTP_PORTS surface ({HttpPorts}/{HttpsPorts}) " +
|
||||
"alongside the sync port.", httpPorts, httpsPorts);
|
||||
}
|
||||
|
||||
// A driver-only Windows-service node gets NO URLS and NO *_PORTS (Install-Services.ps1 sets URLS
|
||||
// only under $hasAdmin), so "nothing configured" is a real, supported state — not an error.
|
||||
// Kestrel's own default is localhost:5000; re-state it explicitly, because taking over
|
||||
// configuration means taking over the default too. Health probes hit this port.
|
||||
if (existingBindings.Count == 0)
|
||||
{
|
||||
existingBindings = [new KestrelHttpBinding("localhost", 5000, IsSecure: false)];
|
||||
Log.Information(
|
||||
"LocalDb sync listener enabled with no URLs configured; re-binding Kestrel's default " +
|
||||
"http://localhost:5000 alongside the sync port.");
|
||||
}
|
||||
|
||||
// Re-binding an https endpoint would need its certificate configuration replayed too, which
|
||||
// this host does not model (TLS is terminated at Traefik). Rather than silently serve it
|
||||
// without TLS — or drop it — refuse to take over Kestrel at all and leave the existing
|
||||
// surface exactly as it was. The operator gets a loud, actionable error instead of an
|
||||
// AdminUI that stopped answering.
|
||||
if (existingBindings.Any(b => b.IsSecure))
|
||||
{
|
||||
Log.Error(
|
||||
"LocalDb:SyncListenPort is set to {Port} but this host serves HTTPS endpoint(s) ({Urls}). " +
|
||||
"Binding the sync listener requires re-binding every existing endpoint explicitly, and the " +
|
||||
"HTTPS certificate configuration cannot be replayed safely. The sync listener is DISABLED; " +
|
||||
"terminate TLS upstream (as the docker-dev rig does) or leave replication off on this node.",
|
||||
syncListenPort, configuredUrls);
|
||||
syncListenPort = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
foreach (var binding in existingBindings)
|
||||
binding.Apply(kestrel);
|
||||
|
||||
kestrel.ListenAnyIP(syncListenPort, o => o.Protocols = HttpProtocols.Http2);
|
||||
});
|
||||
|
||||
Log.Information(
|
||||
"LocalDb sync listener bound on :{SyncPort} (h2c); re-bound existing endpoint(s) {Urls}.",
|
||||
syncListenPort, configuredUrls ?? "http://localhost:5000 (Kestrel default)");
|
||||
}
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// AddZbSerilog registers Serilog as the MEL logging provider but does NOT assign the static
|
||||
@@ -356,6 +497,15 @@ if (hasAdmin)
|
||||
app.MapOtOpcUaDeployApi(app.Configuration);
|
||||
}
|
||||
|
||||
// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
|
||||
// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be safe
|
||||
// even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey configured — but
|
||||
// not mapping it at all is the stronger default.
|
||||
if (hasDriver && syncListenPort > 0)
|
||||
{
|
||||
app.MapZbLocalDbSync();
|
||||
}
|
||||
|
||||
app.MapOtOpcUaHealth();
|
||||
app.MapOtOpcUaMetrics();
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
<!-- LocalDb: node-local SQLite cache (AddZbLocalDb) plus the replication sync engine and
|
||||
its passive gRPC endpoint (AddZbLocalDbReplication / MapZbLocalDbSync). Grpc.AspNetCore
|
||||
is required because the sync endpoint is served by this host's Kestrel, not dialled by
|
||||
it — Grpc.Net.Client (already pinned) only covers the initiator side. -->
|
||||
<PackageReference Include="Grpc.AspNetCore" />
|
||||
<PackageReference Include="ZB.MOM.WW.LocalDb" />
|
||||
<PackageReference Include="ZB.MOM.WW.LocalDb.Replication" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,12 +11,27 @@
|
||||
"DisableLogin": false
|
||||
}
|
||||
},
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": {
|
||||
"Source": "Environment",
|
||||
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||
},
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30",
|
||||
"Replication": {
|
||||
"_comment": "Peer-to-peer secret replication over the Akka cluster. OPT-IN: Enabled=false keeps the plain local SQLite store on every node. KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 — that version's ISecretReplicator registration is shadowed by the NoOp one AddZbSecrets registers first, so enabling this decorates the store but replicates nothing and starts no actor. Do not enable until the library is fixed. All nodes must also share the same KEK.",
|
||||
"Enabled": false,
|
||||
"AnnounceInterval": "00:00:30",
|
||||
"ActorName": "zb-secret-replication"
|
||||
}
|
||||
},
|
||||
"ServerHistorian": {
|
||||
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
||||
"Enabled": false,
|
||||
"Endpoint": "",
|
||||
"ApiKey": "",
|
||||
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.",
|
||||
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
|
||||
"UseTls": true,
|
||||
"AllowUntrustedServerCertificate": false,
|
||||
"CaCertificatePath": null,
|
||||
@@ -44,6 +59,16 @@
|
||||
"Capacity": 1000000,
|
||||
"DeadLetterRetentionDays": 30
|
||||
},
|
||||
"LocalDb": {
|
||||
"_comment": "Node-local SQLite store caching the deployed-configuration artifact, so a driver node can boot from its last-known-good config when central SQL is unreachable. Registered on driver-role nodes only (see LocalDbRegistration). Storage is unconditional; replication to the redundant pair peer is default-OFF.",
|
||||
"Path": "./data/otopcua-localdb.db",
|
||||
"_SyncListenPortComment": "Port for the dedicated cleartext-HTTP/2 (h2c) listener serving the passive sync endpoint. 0 = no listener bound and the endpoint is not mapped. Must be a port distinct from the main HTTP listener: prior-knowledge h2c cannot share a cleartext Http1AndHttp2 port.",
|
||||
"SyncListenPort": 0,
|
||||
"Replication": {
|
||||
"_comment": "Empty = passive/off. Set PeerAddress on exactly ONE node of the pair (the stream is bidirectional, so the other side still pushes its own deltas back). ApiKey must be BYTE-IDENTICAL on both nodes — the host interceptor fail-closes, so a typo silently stops convergence rather than degrading to unauthenticated. Supply it via ${secret:...} in production, never committed.",
|
||||
"_MaxBatchSizeComment": "Row count, NOT bytes, against gRPC's 4 MB default message cap. Artifact chunk rows are ~171 KB base64, so keep this at 16 on the pair (16 x 171 KB ~= 2.7 MB worst case)."
|
||||
}
|
||||
},
|
||||
"Deployment": {
|
||||
"_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.",
|
||||
"TagConfigValidationMode": "Warn"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
|
||||
var failedNodes = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
|
||||
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
|
||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
|
||||
// removed raw containers/tags + UNS reference variables are likewise real removals.
|
||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
|
||||
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedEquipmentTags.Count +
|
||||
plan.ChangedEquipmentVirtualTags.Count +
|
||||
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
|
||||
// A UNS Area/Line rename is an in-place change to an existing folder node.
|
||||
plan.RenamedFolders.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count;
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
|
||||
|
||||
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
||||
@@ -172,7 +177,7 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var rename in renamedFolders)
|
||||
{
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
|
||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
|
||||
@@ -185,13 +190,13 @@ public sealed class AddressSpaceApplier
|
||||
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
||||
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
||||
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
|
||||
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||
: null;
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); }
|
||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
||||
@@ -263,77 +268,108 @@ public sealed class AddressSpaceApplier
|
||||
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
||||
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||
// closed), then remove the condition in place.
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
|
||||
// written by the top-of-Apply removal block; here we tear the condition node down in place.
|
||||
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
|
||||
{
|
||||
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false;
|
||||
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
|
||||
// top-of-Apply block already wrote the equipment id's terminal condition state.
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false;
|
||||
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
|
||||
// in-place remove. A backing-raw rename is a re-point (Changed, → rebuild), NOT a removal, so a UNS
|
||||
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
|
||||
foreach (var v in plan.RemovedUnsReferenceVariables)
|
||||
{
|
||||
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
|
||||
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
|
||||
// + RemoveVariableNode).
|
||||
foreach (var t in plan.RemovedRawTags)
|
||||
{
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
|
||||
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the
|
||||
// sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so
|
||||
// the cheap variable/condition removals above still run in place when only tags were removed.
|
||||
if (plan.RemovedRawContainers.Count > 0) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
try { return surgical.RemoveVariableNode(nodeId); }
|
||||
try { return surgical.RemoveVariableNode(nodeId, realm); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
try { return surgical.RemoveAlarmConditionNode(nodeId); }
|
||||
try { return surgical.RemoveAlarmConditionNode(nodeId, realm); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
try { return surgical.RemoveEquipmentSubtree(nodeId); }
|
||||
try { return surgical.RemoveEquipmentSubtree(nodeId, realm); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
|
||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
|
||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
||||
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
|
||||
{
|
||||
try { _sink.WriteValue(nodeId, value, quality, ts); return true; }
|
||||
try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
@@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier
|
||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
||||
/// <returns>The materialise-parent node id.</returns>
|
||||
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
|
||||
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
||||
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath);
|
||||
|
||||
// ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) -----
|
||||
// Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the
|
||||
// discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}),
|
||||
// now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go.
|
||||
// These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated
|
||||
// by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name
|
||||
// NodeId directly and are NOT built here.)
|
||||
private static string UnsSubFolder(string equipmentId, string folderPath) =>
|
||||
V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)));
|
||||
|
||||
private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) =>
|
||||
string.IsNullOrWhiteSpace(folderPath)
|
||||
? V3NodeIds.Uns(equipmentId, name)
|
||||
: V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name));
|
||||
|
||||
/// <summary>
|
||||
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
|
||||
@@ -389,7 +440,7 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
foreach (var id in ComputeAddAnnouncements(plan))
|
||||
{
|
||||
try { _sink.RaiseNodesAddedModelChange(id); }
|
||||
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
|
||||
}
|
||||
}
|
||||
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
|
||||
try
|
||||
{
|
||||
List<HistorianTagProvisionRequest>? requests = null;
|
||||
foreach (var tag in plan.AddedEquipmentTags)
|
||||
// v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags
|
||||
// materialise as Part 9 conditions (never historized value variables), so mirror the materialiser
|
||||
// and skip them.
|
||||
foreach (var tag in plan.AddedRawTags)
|
||||
{
|
||||
// Only historized value variables are provisioned. Native-alarm tags materialise as
|
||||
// Part 9 condition nodes (never historized value variables) — the materialiser resolves
|
||||
// a historian tagname only for the non-alarm branch, so mirror that and skip them.
|
||||
if (!tag.IsHistorized || tag.Alarm is not null) continue;
|
||||
|
||||
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
|
||||
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
|
||||
// override falls back to the driver-side FullName.
|
||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
|
||||
// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
|
||||
// falls back to the RawPath (== NodeId).
|
||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
|
||||
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
|
||||
}
|
||||
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
|
||||
List<HistorizedTagRef>? added = null;
|
||||
List<HistorizedTagRef>? removed = null;
|
||||
|
||||
// Added historized value variables → new interest.
|
||||
foreach (var tag in plan.AddedEquipmentTags)
|
||||
// v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
|
||||
// stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the
|
||||
// node manager but does NOT add a second mux ref (no double historization). So this feed emits raw
|
||||
// refs only.
|
||||
// Added historized raw value tags → new interest.
|
||||
foreach (var tag in plan.AddedRawTags)
|
||||
{
|
||||
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
|
||||
}
|
||||
|
||||
// Removed historized value variables → drop interest.
|
||||
foreach (var tag in plan.RemovedEquipmentTags)
|
||||
// Removed historized raw value tags → drop interest.
|
||||
foreach (var tag in plan.RemovedRawTags)
|
||||
{
|
||||
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
|
||||
}
|
||||
|
||||
// Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName
|
||||
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
|
||||
// mux ref and the historian name) — an unchanged pair is a no-op.
|
||||
foreach (var d in plan.ChangedEquipmentTags)
|
||||
// Changed raw tags: the historized ref may have flipped on/off or the historian override changed
|
||||
// (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
|
||||
// unchanged pair is a no-op.
|
||||
foreach (var d in plan.ChangedRawTags)
|
||||
{
|
||||
var prev = HistorizedRef(d.Previous);
|
||||
var cur = HistorizedRef(d.Current);
|
||||
@@ -540,13 +595,15 @@ public sealed class AddressSpaceApplier
|
||||
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
|
||||
/// value variable (not historized, or a native-alarm condition node).
|
||||
/// </summary>
|
||||
/// <param name="tag">The equipment tag to resolve a historized ref for.</param>
|
||||
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
|
||||
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
|
||||
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
|
||||
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
|
||||
tag.IsHistorized && tag.Alarm is null
|
||||
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
|
||||
// default historian tagname; an override diverges only the historian name.
|
||||
? new HistorizedTagRef(
|
||||
tag.FullName,
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
tag.NodeId,
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
|
||||
: null;
|
||||
|
||||
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
|
||||
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
|
||||
var failed = 0;
|
||||
foreach (var area in composition.UnsAreas)
|
||||
{
|
||||
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++;
|
||||
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
foreach (var line in composition.UnsLines)
|
||||
{
|
||||
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++;
|
||||
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
foreach (var equipment in composition.EquipmentNodes)
|
||||
{
|
||||
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
||||
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
|
||||
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++;
|
||||
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -604,6 +661,153 @@ public sealed class AddressSpaceApplier
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — materialise the <b>Raw</b> device-oriented subtree (<c>ns=Raw</c>): the container
|
||||
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
|
||||
/// then the tag Variable nodes keyed <c>s=<RawPath></c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
|
||||
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
|
||||
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
|
||||
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
|
||||
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
|
||||
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
|
||||
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the Raw subtree.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||
public int MaterialiseRawSubtree(AddressSpaceComposition composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
|
||||
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
|
||||
foreach (var c in composition.RawContainers)
|
||||
{
|
||||
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
}
|
||||
|
||||
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
|
||||
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
|
||||
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
|
||||
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
|
||||
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
|
||||
|
||||
foreach (var t in composition.RawTags)
|
||||
{
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
|
||||
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
|
||||
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
else if (t.ReferencingEquipmentPaths.Count > 0)
|
||||
{
|
||||
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
|
||||
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
|
||||
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
|
||||
// (the id scheme MaterialiseHierarchy created the equipment folders under).
|
||||
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
|
||||
var equipFolderNodeIds = t.ReferencingEquipmentPaths
|
||||
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Select(id => id!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (equipFolderNodeIds.Count == 0)
|
||||
{
|
||||
// Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved
|
||||
// to an EquipmentId folder — the native fan-out to those equipment is silently absent.
|
||||
// Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead
|
||||
// of swallowing zero operator signal. Today this only happens on a broken UNS ancestry /
|
||||
// an invalid segment (the composer would have dropped the equipment too); the latent
|
||||
// risk is a future editable Area/Line display-name feature breaking the path↔id coupling
|
||||
// (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests).
|
||||
_logger.LogWarning(
|
||||
"AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})",
|
||||
t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths));
|
||||
failed++;
|
||||
}
|
||||
else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
|
||||
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
|
||||
string? historianTagname = t.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
|
||||
: null;
|
||||
var writable = t.Writable && !t.IsArray;
|
||||
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})",
|
||||
composition.RawContainers.Count, composition.RawTags.Count, failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — materialise the <b>UNS</b> reference Variable nodes (<c>ns=UNS</c>): one per
|
||||
/// <see cref="UnsReferenceVariable"/>, keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>,
|
||||
/// parented under its equipment folder (the logical <see cref="UnsReferenceVariable.EquipmentId"/> node
|
||||
/// <see cref="MaterialiseHierarchy"/> already created), THEN an <c>Organizes</c> reference
|
||||
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
|
||||
/// tagname from its backing RAW tag (looked up by <see cref="UnsReferenceVariable.BackingRawPath"/>), so a
|
||||
/// historized raw tag's UNS reference registers the SAME tagname (both NodeIds → one tagname → HistoryRead
|
||||
/// works against either). The UNS node binds NO driver — the driver publish path fans values to it (WP3
|
||||
/// runtime binding). Every sink call passes <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge
|
||||
/// passes both realms EXPLICITLY. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the UNS reference variables + Raw tags (for inherited shape).</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||
public int MaterialiseUnsReferences(AddressSpaceComposition composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.UnsReferenceVariables.Count == 0) return 0;
|
||||
|
||||
// Backing raw tag shape/historian by RawPath — a UNS reference inherits these so its node matches the
|
||||
// raw node it mirrors (a historized raw tag registers the SAME tagname on the UNS node for HistoryRead).
|
||||
var rawByPath = new Dictionary<string, RawTagPlan>(StringComparer.Ordinal);
|
||||
foreach (var t in composition.RawTags) rawByPath[t.NodeId] = t;
|
||||
|
||||
var failed = 0;
|
||||
foreach (var v in composition.UnsReferenceVariables)
|
||||
{
|
||||
var backing = rawByPath.GetValueOrDefault(v.BackingRawPath);
|
||||
var isArray = backing?.IsArray ?? false;
|
||||
uint? arrayLength = backing?.ArrayLength;
|
||||
// A UNS reference to a historized raw tag registers the SAME effective historian tagname (override
|
||||
// else the backing RawPath) so HistoryRead resolves against the UNS NodeId too. The mux ref stays
|
||||
// single (keyed by RawPath) — FeedHistorizedRefs emits raw refs only.
|
||||
string? historianTagname = backing is { IsHistorized: true }
|
||||
? (string.IsNullOrWhiteSpace(backing.HistorianTagname) ? backing.NodeId : backing.HistorianTagname)
|
||||
: null;
|
||||
var writable = v.Writable && !isArray;
|
||||
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
|
||||
{
|
||||
failed++;
|
||||
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
|
||||
}
|
||||
// Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing
|
||||
// endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy.
|
||||
try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; }
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})",
|
||||
composition.UnsReferenceVariables.Count, failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace tags from a composition snapshot.
|
||||
/// For each <see cref="EquipmentTagPlan"/>,
|
||||
@@ -635,9 +839,9 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
|
||||
@@ -650,13 +854,13 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
|
||||
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
|
||||
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++;
|
||||
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -670,7 +874,7 @@ public sealed class AddressSpaceApplier
|
||||
// even if authored ReadWrite, so a client write cannot reach the driver write path which
|
||||
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
|
||||
var writable = tag.Writable && !tag.IsArray;
|
||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,17 +912,17 @@ public sealed class AddressSpaceApplier
|
||||
var failed = 0;
|
||||
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
|
||||
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
|
||||
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++;
|
||||
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
|
||||
foreach (var v in variables)
|
||||
{
|
||||
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
|
||||
var writable = v.Writable && !v.IsArray;
|
||||
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
|
||||
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
|
||||
AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
|
||||
}
|
||||
|
||||
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
|
||||
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
|
||||
@@ -754,9 +958,9 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var v in composition.EquipmentVirtualTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
||||
var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||
var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
|
||||
@@ -769,10 +973,10 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
||||
? v.EquipmentId
|
||||
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||
: UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||
// VirtualTags are computed outputs — read-only nodes (no inbound write).
|
||||
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++;
|
||||
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -805,7 +1009,7 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||
{
|
||||
if (!alarm.Enabled) continue;
|
||||
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++;
|
||||
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
materialised++;
|
||||
}
|
||||
|
||||
@@ -822,9 +1026,9 @@ public sealed class AddressSpaceApplier
|
||||
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
|
||||
/// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
{
|
||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; }
|
||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
@@ -832,9 +1036,9 @@ public sealed class AddressSpaceApplier
|
||||
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
|
||||
/// count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; }
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
@@ -859,9 +1063,9 @@ public sealed class AddressSpaceApplier
|
||||
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
|
||||
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
|
||||
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
|
||||
{
|
||||
try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; }
|
||||
try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
@@ -869,11 +1073,70 @@ public sealed class AddressSpaceApplier
|
||||
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
|
||||
/// their pass's failed-node count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
|
||||
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm)
|
||||
{
|
||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; }
|
||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
|
||||
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
|
||||
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
{
|
||||
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
|
||||
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
|
||||
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
|
||||
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
|
||||
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
|
||||
/// construction EXACTLY — <b>path↔id coupling invariant:</b> the composer builds the path from the
|
||||
/// Area/Line/Equipment <c>Name</c>; this inverts it via the projected <c>DisplayName</c>, which the
|
||||
/// composer sets to that same <c>Name</c> at every level (verified by
|
||||
/// <c>AddressSpaceComposerPathParityTests</c>). A future editable Area/Line display-name feature that
|
||||
/// lets <c>DisplayName != Name</c> would silently break this inversion — that test is the tripwire.
|
||||
/// An invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same drop the composer
|
||||
/// applies), so an entry the composer produced always resolves here.
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
|
||||
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
|
||||
private IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
|
||||
{
|
||||
var areaName = composition.UnsAreas
|
||||
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
|
||||
var lineByid = composition.UnsLines
|
||||
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
|
||||
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var e in composition.EquipmentNodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
|
||||
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
|
||||
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
|
||||
string path;
|
||||
try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); }
|
||||
catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ }
|
||||
// Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id
|
||||
// (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the
|
||||
// composer's own last-write-wins spot) rather than silently pick one — a collision here would send
|
||||
// the alarm's notifier to the wrong equipment folder.
|
||||
if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this",
|
||||
path, existing, e.EquipmentId);
|
||||
}
|
||||
map[path] = e.EquipmentId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
|
||||
|
||||
@@ -53,10 +53,24 @@ public static class AddressSpaceChangeClassifier
|
||||
|
||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
|
||||
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
|
||||
// Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point /
|
||||
// display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical
|
||||
// in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change
|
||||
// alongside pure adds still rebuilds.
|
||||
//
|
||||
// WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier
|
||||
// wiring correct today. A native alarm's set of referencing equipment lives in
|
||||
// RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference
|
||||
// makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers
|
||||
// cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm
|
||||
// notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus
|
||||
// an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events.
|
||||
if (plan.ChangedEquipment.Count > 0 ||
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedRawContainers.Count > 0 ||
|
||||
plan.ChangedRawTags.Count > 0 ||
|
||||
plan.ChangedUnsReferenceVariables.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||
{
|
||||
@@ -68,10 +82,14 @@ public static class AddressSpaceChangeClassifier
|
||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||
var hasAdds =
|
||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0;
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
||||
plan.AddedUnsReferenceVariables.Count > 0;
|
||||
var hasRemoves =
|
||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0;
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
|
||||
plan.RemovedUnsReferenceVariables.Count > 0;
|
||||
|
||||
// 3 — additions AND removals.
|
||||
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
|
||||
/// constructor + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
|
||||
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
|
||||
|
||||
/// <summary>
|
||||
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
|
||||
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
|
||||
/// carries its <c>s=<RawPath></c> NodeId and its parent NodeId (<see langword="null"/> = cluster
|
||||
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
|
||||
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
|
||||
/// + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
|
||||
/// <summary>
|
||||
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
|
||||
/// <c>(realm=Raw, s=<RawPath>)</c>. The RawPath IS the node identity + the single value source
|
||||
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
|
||||
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
|
||||
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
|
||||
/// multi-notifier alarm). Init-only, defaults empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
|
||||
/// <summary>
|
||||
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
|
||||
/// <c>(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>)</c>. Each carries
|
||||
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
|
||||
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
|
||||
/// out from the raw node. Init-only, defaults empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
}
|
||||
|
||||
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
|
||||
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
|
||||
public enum RawNodeKind
|
||||
{
|
||||
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
|
||||
Folder,
|
||||
|
||||
/// <summary>A <see cref="DriverInstance"/>.</summary>
|
||||
Driver,
|
||||
|
||||
/// <summary>A <see cref="Device"/>.</summary>
|
||||
Device,
|
||||
|
||||
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
|
||||
TagGroup,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
|
||||
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
|
||||
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
|
||||
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
|
||||
/// call site rather than inferring it.
|
||||
/// </summary>
|
||||
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
|
||||
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
|
||||
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
|
||||
/// <param name="Kind">Which container this is.</param>
|
||||
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
|
||||
public sealed record RawContainerNode(
|
||||
string NodeId,
|
||||
string? ParentNodeId,
|
||||
string DisplayName,
|
||||
RawNodeKind Kind,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
|
||||
|
||||
/// <summary>
|
||||
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
|
||||
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
|
||||
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
|
||||
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
|
||||
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
|
||||
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
|
||||
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
|
||||
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
|
||||
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
|
||||
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
|
||||
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
||||
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
||||
/// later, not here).
|
||||
/// </summary>
|
||||
public sealed record RawTagPlan(
|
||||
string TagId,
|
||||
string NodeId,
|
||||
string? ParentNodeId,
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DataType,
|
||||
bool Writable,
|
||||
EquipmentTagAlarmInfo? Alarm,
|
||||
IReadOnlyList<string> ReferencingEquipmentPaths,
|
||||
bool IsHistorized = false,
|
||||
string? HistorianTagname = null,
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
|
||||
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
|
||||
// EquipmentVirtualTagPlan).
|
||||
public bool Equals(RawTagPlan? other) =>
|
||||
other is not null &&
|
||||
TagId == other.TagId &&
|
||||
NodeId == other.NodeId &&
|
||||
ParentNodeId == other.ParentNodeId &&
|
||||
DriverInstanceId == other.DriverInstanceId &&
|
||||
Name == other.Name &&
|
||||
DataType == other.DataType &&
|
||||
Writable == other.Writable &&
|
||||
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
|
||||
IsHistorized == other.IsHistorized &&
|
||||
HistorianTagname == other.HistorianTagname &&
|
||||
IsArray == other.IsArray &&
|
||||
ArrayLength == other.ArrayLength &&
|
||||
Realm == other.Realm &&
|
||||
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
||||
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||
hash.Add(Realm);
|
||||
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
|
||||
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
|
||||
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
|
||||
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
|
||||
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
|
||||
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
|
||||
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
|
||||
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
|
||||
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
|
||||
/// <see cref="AddressSpaceRealm.Uns"/>.
|
||||
/// </summary>
|
||||
public sealed record UnsReferenceVariable(
|
||||
string UnsTagReferenceId,
|
||||
string EquipmentId,
|
||||
string NodeId,
|
||||
string EffectiveName,
|
||||
string BackingRawPath,
|
||||
string DataType,
|
||||
bool Writable,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
|
||||
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
|
||||
|
||||
/// <summary>
|
||||
/// Composes the address space build plan from the v3 configuration entities.
|
||||
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
|
||||
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
|
||||
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
|
||||
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
|
||||
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
|
||||
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
|
||||
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
|
||||
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
|
||||
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
|
||||
/// <c>s=<RawPath></c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
|
||||
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
|
||||
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>, carrying its backing RawPath
|
||||
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
|
||||
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
|
||||
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
|
||||
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
|
||||
/// unchanged.</para>
|
||||
/// </summary>
|
||||
/// <param name="unsAreas">The UNS areas.</param>
|
||||
/// <param name="unsLines">The UNS lines.</param>
|
||||
@@ -366,15 +527,19 @@ public static class AddressSpaceComposer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
|
||||
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
|
||||
// every value (see RawTags / UnsReferenceVariables below).
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
|
||||
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
|
||||
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
|
||||
// use, so the three agree byte-for-byte on the resolved RawPath.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(
|
||||
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
|
||||
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
|
||||
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
|
||||
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
|
||||
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
|
||||
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
|
||||
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
|
||||
// artifact-decode mirror + the draft validator use.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -451,45 +616,38 @@ public static class AddressSpaceComposer
|
||||
Enabled: a.Enabled));
|
||||
}
|
||||
|
||||
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
|
||||
var (rawContainers, rawTags) = BuildRawSubtree(
|
||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
|
||||
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
|
||||
/// are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
|
||||
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
|
||||
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups)
|
||||
RawPathResolver resolver)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
var tagRows = tags ?? Array.Empty<Tag>();
|
||||
if (refs.Count == 0 || tagRows.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
var folders = (rawFolders ?? Array.Empty<RawFolder>())
|
||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var drivers = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var deviceMap = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groups = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
|
||||
|
||||
var tagsById = tagRows
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
@@ -503,4 +661,363 @@ public static class AddressSpaceComposer
|
||||
tagsById,
|
||||
resolver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
|
||||
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
|
||||
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
|
||||
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
|
||||
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
|
||||
/// </summary>
|
||||
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology)
|
||||
{
|
||||
var containers = new List<RawContainerNode>();
|
||||
|
||||
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
|
||||
{
|
||||
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
|
||||
var parent = f.ParentRawFolderId is not null
|
||||
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
|
||||
: null;
|
||||
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
|
||||
}
|
||||
|
||||
foreach (var d in driverInstances)
|
||||
{
|
||||
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
|
||||
var parent = d.RawFolderId is not null
|
||||
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
|
||||
: null;
|
||||
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
|
||||
}
|
||||
|
||||
foreach (var dev in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
|
||||
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
|
||||
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
|
||||
}
|
||||
|
||||
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
|
||||
{
|
||||
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
|
||||
var parent = g.ParentTagGroupId is not null
|
||||
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
|
||||
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
|
||||
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
|
||||
}
|
||||
|
||||
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
||||
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||
|
||||
var rawTags = new List<RawTagPlan>();
|
||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||
{
|
||||
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
if (nodeId is null) continue;
|
||||
var parent = t.TagGroupId is not null
|
||||
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
|
||||
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
||||
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
var alarm = intent.Alarm is { } a
|
||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||
: null;
|
||||
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
|
||||
|
||||
rawTags.Add(new RawTagPlan(
|
||||
TagId: t.TagId,
|
||||
NodeId: nodeId,
|
||||
ParentNodeId: parent,
|
||||
DriverInstanceId: driverInstanceId,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: alarm,
|
||||
ReferencingEquipmentPaths: refEquipPaths,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
}
|
||||
|
||||
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
return (containers, rawTags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
|
||||
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
|
||||
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
|
||||
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||
|
||||
var tagsById = (tags ?? Array.Empty<Tag>())
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||
|
||||
var vars = new List<UnsReferenceVariable>();
|
||||
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
||||
{
|
||||
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
||||
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||
if (rawPath is null) continue;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
||||
|
||||
string nodeId;
|
||||
try
|
||||
{
|
||||
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
|
||||
}
|
||||
|
||||
vars.Add(new UnsReferenceVariable(
|
||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||
EquipmentId: r.EquipmentId,
|
||||
NodeId: nodeId,
|
||||
EffectiveName: effectiveName,
|
||||
BackingRawPath: rawPath,
|
||||
DataType: tag.DataType,
|
||||
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite));
|
||||
}
|
||||
|
||||
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
return vars;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
|
||||
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
|
||||
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment)
|
||||
{
|
||||
var areaName = unsAreas
|
||||
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
var line = unsLines
|
||||
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
|
||||
|
||||
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var e in equipment)
|
||||
{
|
||||
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
|
||||
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
|
||||
try
|
||||
{
|
||||
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
|
||||
}
|
||||
}
|
||||
return byEquip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
|
||||
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
|
||||
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
|
||||
/// resolvable reference is absent (⇒ an empty list at the call site).
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||
if (!byTag.TryGetValue(r.TagId, out var set))
|
||||
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
set.Add(equipPath);
|
||||
}
|
||||
return byTag.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (IReadOnlyList<string>)kv.Value.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
|
||||
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
|
||||
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
|
||||
/// reference map so every RawPath is produced by the single authority.
|
||||
/// </summary>
|
||||
private sealed class RawTopology
|
||||
{
|
||||
public required RawPathResolver Resolver { get; init; }
|
||||
|
||||
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
|
||||
|
||||
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
|
||||
|
||||
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
|
||||
|
||||
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
|
||||
|
||||
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
|
||||
|
||||
public static RawTopology Build(
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups)
|
||||
{
|
||||
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
|
||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var driverRows = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var deviceRows = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
|
||||
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
|
||||
|
||||
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
|
||||
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var id in folderRows.Keys)
|
||||
ResolveFolder(id, folderRows, folderPaths, 0);
|
||||
|
||||
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var id in driverRows.Keys)
|
||||
{
|
||||
var p = resolver.TryBuildDriverPrefix(id);
|
||||
if (p is not null) driverPaths[id] = p;
|
||||
}
|
||||
|
||||
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (id, dev) in deviceRows)
|
||||
{
|
||||
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
|
||||
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
|
||||
catch (ArgumentException) { /* invalid device name — dropped */ }
|
||||
}
|
||||
|
||||
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
|
||||
foreach (var id in groupRows.Keys)
|
||||
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
|
||||
|
||||
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
|
||||
|
||||
return new RawTopology
|
||||
{
|
||||
Resolver = resolver,
|
||||
FolderPaths = folderPaths,
|
||||
DriverPaths = driverPaths,
|
||||
DevicePaths = devicePaths,
|
||||
GroupPaths = groupPaths,
|
||||
DriverIdByDevice = driverIdByDevice,
|
||||
};
|
||||
}
|
||||
|
||||
private const int MaxDepth = 256;
|
||||
|
||||
private static string? ResolveFolder(
|
||||
string id,
|
||||
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||
Dictionary<string, string> memo,
|
||||
int depth)
|
||||
{
|
||||
if (depth > MaxDepth) return null;
|
||||
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||
if (!rows.TryGetValue(id, out var row)) return null;
|
||||
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
|
||||
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
|
||||
try
|
||||
{
|
||||
var path = RawPaths.Combine(parentPath, row.Name);
|
||||
memo[id] = path;
|
||||
return path;
|
||||
}
|
||||
catch (ArgumentException) { return null; }
|
||||
}
|
||||
|
||||
private static string? ResolveGroup(
|
||||
string id,
|
||||
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||
IReadOnlyDictionary<string, string> groupDeviceById,
|
||||
IReadOnlyDictionary<string, string> devicePaths,
|
||||
Dictionary<string, string> memo,
|
||||
int depth)
|
||||
{
|
||||
if (depth > MaxDepth) return null;
|
||||
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||
if (!rows.TryGetValue(id, out var row)) return null;
|
||||
|
||||
string? parentPath;
|
||||
if (row.ParentId is not null)
|
||||
{
|
||||
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
|
||||
if (parentPath is null) return null; // broken parent chain
|
||||
}
|
||||
else
|
||||
{
|
||||
// Root group: parent is the owning device's RawPath.
|
||||
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|
||||
|| !devicePaths.TryGetValue(deviceId, out parentPath))
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = RawPaths.Combine(parentPath, row.Name);
|
||||
memo[id] = path;
|
||||
return path;
|
||||
}
|
||||
catch (ArgumentException) { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,46 @@ public sealed record AddressSpacePlan(
|
||||
/// </summary>
|
||||
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the Raw subtree's <b>container</b> diff (Folder/Driver/Device/TagGroup nodes),
|
||||
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
|
||||
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
|
||||
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
|
||||
/// unchanged (consistent with the EquipmentTag diff sets above).
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
public IReadOnlyList<RawContainerNode> RemovedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
public IReadOnlyList<RawContainerDelta> ChangedRawContainers { get; init; } = Array.Empty<RawContainerDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the Raw subtree's <b>tag</b> diff, keyed by <see cref="RawTagPlan.NodeId"/> (the
|
||||
/// RawPath). A raw tag <b>rename</b> changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
|
||||
/// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
|
||||
/// the same NodeId ⇒ a <see cref="RawTagDelta"/>. Init-only, defaulting empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagPlan> AddedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
/// <inheritdoc cref="AddedRawTags"/>
|
||||
public IReadOnlyList<RawTagPlan> RemovedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
/// <inheritdoc cref="AddedRawTags"/>
|
||||
public IReadOnlyList<RawTagDelta> ChangedRawTags { get; init; } = Array.Empty<RawTagDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
|
||||
/// <see cref="UnsReferenceVariable.UnsTagReferenceId"/> (the stable row id, NOT the NodeId). Keying on
|
||||
/// the row id is what makes a backing-raw-tag rename a <b>re-point</b> (same reference row, same
|
||||
/// effective name ⇒ same UNS NodeId, but <see cref="UnsReferenceVariable.BackingRawPath"/> — the
|
||||
/// <c>Organizes</c> target + fan-out source — moves) rather than a remove+add; and a
|
||||
/// display-name-override edit a <see cref="UnsReferenceDelta"/> (same row id, NodeId + effective name
|
||||
/// change) with NO raw-side change. Init-only, defaulting empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UnsReferenceVariable> AddedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||
public IReadOnlyList<UnsReferenceVariable> RemovedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||
public IReadOnlyList<UnsReferenceDelta> ChangedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceDelta>();
|
||||
|
||||
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
|
||||
public bool IsEmpty =>
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
@@ -76,6 +116,9 @@ public sealed record AddressSpacePlan(
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
||||
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
||||
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
|
||||
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
|
||||
AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
|
||||
RenamedFolders.Count == 0;
|
||||
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
@@ -84,6 +127,16 @@ public sealed record AddressSpacePlan(
|
||||
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
||||
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
||||
|
||||
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
|
||||
public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
|
||||
/// <summary>A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
|
||||
/// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.</summary>
|
||||
public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
|
||||
/// <summary>A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
|
||||
/// field — a backing-tag re-point (<c>BackingRawPath</c> moved) or a display-name-override edit (NodeId +
|
||||
/// effective name changed).</summary>
|
||||
public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);
|
||||
|
||||
/// <summary>One renamed UNS Area / Line folder: the stable folder
|
||||
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
|
||||
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
|
||||
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
|
||||
t => t.VirtualTagId,
|
||||
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
|
||||
|
||||
// Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
|
||||
// as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
|
||||
// changes (none today — a container carries only DisplayName == leaf name, which lives in the
|
||||
// RawPath) fall to Changed, kept for completeness / future fields.
|
||||
var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
|
||||
previous.RawContainers, next.RawContainers,
|
||||
c => c.NodeId,
|
||||
(a, b) => new AddressSpacePlan.RawContainerDelta(a, b));
|
||||
|
||||
// Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
|
||||
// attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
|
||||
// equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
|
||||
var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
|
||||
previous.RawTags, next.RawTags,
|
||||
t => t.NodeId,
|
||||
(a, b) => new AddressSpacePlan.RawTagDelta(a, b));
|
||||
|
||||
// UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
|
||||
// the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
|
||||
// NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
|
||||
// Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
|
||||
var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
|
||||
previous.UnsReferenceVariables, next.UnsReferenceVariables,
|
||||
v => v.UnsTagReferenceId,
|
||||
(a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));
|
||||
|
||||
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
|
||||
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
|
||||
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
|
||||
@@ -159,6 +238,15 @@ public static class AddressSpacePlanner
|
||||
AddedEquipmentVirtualTags = addedVTags,
|
||||
RemovedEquipmentVirtualTags = removedVTags,
|
||||
ChangedEquipmentVirtualTags = changedVTags,
|
||||
AddedRawContainers = addedRawContainers,
|
||||
RemovedRawContainers = removedRawContainers,
|
||||
ChangedRawContainers = changedRawContainers,
|
||||
AddedRawTags = addedRawTags,
|
||||
RemovedRawTags = removedRawTags,
|
||||
ChangedRawTags = changedRawTags,
|
||||
AddedUnsReferenceVariables = addedUnsRefs,
|
||||
RemovedUnsReferenceVariables = removedUnsRefs,
|
||||
ChangedUnsReferenceVariables = changedUnsRefs,
|
||||
RenamedFolders = renamedFolders,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,48 +21,59 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
=> _nodeManager.RemoveVariableNode(variableNodeId);
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveVariableNode(variableNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> _nodeManager.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
|
||||
/// <summary>
|
||||
/// DDL for the node-local deployment-artifact cache: the chunked artifact table plus the
|
||||
/// per-cluster current-deployment pointer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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>Why the artifact is chunked base64 TEXT and not one BLOB row.</b> Two independent
|
||||
/// constraints force it. <c>RegisterReplicated</c> rejects BLOB columns outright; and the
|
||||
/// replication engine batches by <i>row count</i> against gRPC's 4 MB default message cap,
|
||||
/// so a single multi-megabyte artifact row would simply never be deliverable — at any
|
||||
/// batch size. A 128 KiB raw chunk is ≈ 171 KB once base64-encoded, which keeps a
|
||||
/// worst-case batch comfortably inside the cap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why no autoincrement PKs.</b> Convergence is last-writer-wins keyed on the primary
|
||||
/// key. Two nodes independently allocating rowid 7 for different rows would silently
|
||||
/// overwrite one another. Every key here is TEXT or a composite of caller-supplied values.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DeploymentCacheSchema
|
||||
{
|
||||
/// <summary>Table holding the artifact bytes, split into base64 chunks.</summary>
|
||||
public const string ArtifactsTable = "deployment_artifacts";
|
||||
|
||||
/// <summary>Table holding one current-deployment pointer per cluster.</summary>
|
||||
public const string PointerTable = "deployment_pointer";
|
||||
|
||||
/// <summary>
|
||||
/// Creates both cache tables if they do 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();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS deployment_artifacts (
|
||||
deployment_id TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
cluster_id TEXT NOT NULL,
|
||||
revision_hash TEXT NOT NULL,
|
||||
chunk_count INTEGER NOT NULL,
|
||||
chunk_base64 TEXT NOT NULL,
|
||||
cached_at_utc TEXT NOT NULL,
|
||||
PRIMARY KEY (deployment_id, chunk_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deployment_pointer (
|
||||
cluster_id TEXT NOT NULL PRIMARY KEY,
|
||||
deployment_id TEXT NOT NULL,
|
||||
revision_hash TEXT NOT NULL,
|
||||
artifact_sha256 TEXT NOT NULL,
|
||||
applied_at_utc TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
|
||||
/// <summary>
|
||||
/// Node-local durable cache of the deployment artifact this node last applied.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Exists so a driver node can boot into its last-known-good address space when the central
|
||||
/// configuration database is unreachable. Without it, a control-plane outage that outlives a
|
||||
/// node restart leaves that node with no address space at all — the plant loses visibility
|
||||
/// for a reason that has nothing to do with the plant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDeploymentArtifactCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists <paramref name="artifact"/> as the cluster's current deployment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotent per <paramref name="deploymentId"/>: re-storing the same deployment
|
||||
/// replaces its chunks rather than accumulating orphans. Retention is bounded to the two
|
||||
/// newest deployments per cluster, so a long-lived node cannot grow its local database
|
||||
/// without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
||||
byte[] artifact, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the cached artifact for <paramref name="clusterId"/>, or <see langword="null"/>
|
||||
/// when nothing is cached or the cached bytes fail their integrity check.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A failed integrity check is deliberately indistinguishable from a miss. Booting a
|
||||
/// plant from a truncated address space is strictly worse than booting from none: the
|
||||
/// missing half looks like a deliberate configuration rather than an error.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the single cached pointer without knowing the cluster id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The boot seam needs this. A node's cluster id is only derivable from an artifact it
|
||||
/// has already loaded, or from the central database — which is precisely what is
|
||||
/// unreachable in the scenario this cache exists to survive. So the cold path reads the
|
||||
/// newest pointer unkeyed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// More than one pointer row means the node was re-homed between clusters. The newest
|
||||
/// wins, but the choice is logged as a warning naming both clusters — a node silently
|
||||
/// booting a neighbouring cluster's configuration is exactly the failure that must never
|
||||
/// be quiet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deployment artifact recovered from the node-local cache, with the metadata needed to decide
|
||||
/// whether it is still current once the control plane is reachable again.
|
||||
/// </summary>
|
||||
public sealed record CachedDeploymentArtifact(
|
||||
string DeploymentId, string RevisionHash, byte[] Artifact, DateTimeOffset AppliedAtUtc);
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDeploymentArtifactCache"/> backed by the replicated LocalDb deployment-cache
|
||||
/// tables.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the artifact is stored as base64 chunks.</b> See
|
||||
/// <see cref="DeploymentCacheSchema"/> — replication rejects BLOB columns and batches by row
|
||||
/// count against gRPC's message cap, so a whole-artifact row could never be delivered.
|
||||
/// Everything in this class that looks like ceremony (chunk indices, a chunk count, a
|
||||
/// separate SHA over the raw bytes) exists to make that split safely reversible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbDeploymentArtifactCache : IDeploymentArtifactCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw bytes per chunk, before base64 expansion.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 128 KiB raw is ≈ 171 KB encoded, which keeps a worst-case replication batch inside gRPC's
|
||||
/// 4 MB default cap with room to spare.
|
||||
/// </remarks>
|
||||
private const int ChunkSize = 128 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// How many deployments per cluster survive a store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two, not one: the previous artifact is what a rollback would need, and keeping it costs
|
||||
/// one artifact's worth of disk. Three would only add a generation nobody rolls back to.
|
||||
/// </remarks>
|
||||
private const int RetainedDeployments = 2;
|
||||
|
||||
private readonly ILocalDb _db;
|
||||
private readonly ILogger<LocalDbDeploymentArtifactCache> _logger;
|
||||
|
||||
public LocalDbDeploymentArtifactCache(ILocalDb db, ILogger<LocalDbDeploymentArtifactCache> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
||||
byte[] artifact, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deploymentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(revisionHash);
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
|
||||
var sha = Convert.ToHexString(SHA256.HashData(artifact));
|
||||
var cachedAtUtc = FormatTimestamp(DateTimeOffset.UtcNow);
|
||||
var chunkCount = (artifact.Length + ChunkSize - 1) / ChunkSize;
|
||||
|
||||
if (await IsAlreadyCachedAsync(clusterId, deploymentId, revisionHash, sha, chunkCount, ct))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Deployment {DeploymentId} (revision {RevisionHash}) is already cached intact for cluster {ClusterId}; skipping the re-store.",
|
||||
deploymentId, revisionHash, clusterId);
|
||||
return;
|
||||
}
|
||||
|
||||
// One transaction for the whole store. A pointer that commits without its chunks — or
|
||||
// chunks that commit without the pointer — is a cache that reads back as a corrupt hit
|
||||
// rather than a clean miss, which is the one outcome this type exists to prevent.
|
||||
await using var tx = await _db.BeginTransactionAsync(ct);
|
||||
|
||||
// Delete-then-insert rather than upsert-per-chunk: a re-store with FEWER chunks than last
|
||||
// time would otherwise leave the old tail behind, and those orphans read back as a
|
||||
// chunk-count mismatch forever.
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId",
|
||||
new { DeploymentId = deploymentId },
|
||||
ct);
|
||||
|
||||
for (var index = 0; index < chunkCount; index++)
|
||||
{
|
||||
var offset = index * ChunkSize;
|
||||
var length = Math.Min(ChunkSize, artifact.Length - offset);
|
||||
|
||||
await tx.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO deployment_artifacts
|
||||
(deployment_id, chunk_index, cluster_id, revision_hash, chunk_count,
|
||||
chunk_base64, cached_at_utc)
|
||||
VALUES
|
||||
(@DeploymentId, @ChunkIndex, @ClusterId, @RevisionHash, @ChunkCount,
|
||||
@ChunkBase64, @CachedAtUtc)
|
||||
""",
|
||||
new
|
||||
{
|
||||
DeploymentId = deploymentId,
|
||||
ChunkIndex = index,
|
||||
ClusterId = clusterId,
|
||||
RevisionHash = revisionHash,
|
||||
ChunkCount = chunkCount,
|
||||
ChunkBase64 = Convert.ToBase64String(artifact, offset, length),
|
||||
CachedAtUtc = cachedAtUtc,
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
await tx.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO deployment_pointer
|
||||
(cluster_id, deployment_id, revision_hash, artifact_sha256, applied_at_utc)
|
||||
VALUES
|
||||
(@ClusterId, @DeploymentId, @RevisionHash, @Sha, @AppliedAtUtc)
|
||||
ON CONFLICT(cluster_id) DO UPDATE SET
|
||||
deployment_id = excluded.deployment_id,
|
||||
revision_hash = excluded.revision_hash,
|
||||
artifact_sha256 = excluded.artifact_sha256,
|
||||
applied_at_utc = excluded.applied_at_utc
|
||||
""",
|
||||
new
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
DeploymentId = deploymentId,
|
||||
RevisionHash = revisionHash,
|
||||
Sha = sha,
|
||||
AppliedAtUtc = cachedAtUtc,
|
||||
},
|
||||
ct);
|
||||
|
||||
// Prune inside the same transaction so the cache is never briefly unbounded.
|
||||
//
|
||||
// The `deployment_id <> @DeploymentId` clause is load-bearing, not belt-and-braces: it makes
|
||||
// "the deployment the pointer names is always present" a structural invariant instead of a
|
||||
// consequence of clock resolution. Without it, three stores landing inside one timestamp
|
||||
// tick fall through to the `deployment_id DESC` tiebreak — and since real deployment ids are
|
||||
// GUIDs, that ordering is effectively random, so the row just written can lose. The pointer
|
||||
// would then name chunks that no longer exist, which reads back as a cache miss on every
|
||||
// subsequent boot until the next deploy overwrites it. Silent and permanent: exactly the
|
||||
// failure this cache exists to prevent.
|
||||
await tx.ExecuteAsync(
|
||||
$"""
|
||||
DELETE FROM deployment_artifacts
|
||||
WHERE cluster_id = @ClusterId
|
||||
AND deployment_id <> @DeploymentId
|
||||
AND deployment_id NOT IN (
|
||||
SELECT deployment_id
|
||||
FROM deployment_artifacts
|
||||
WHERE cluster_id = @ClusterId
|
||||
GROUP BY deployment_id
|
||||
ORDER BY MAX(cached_at_utc) DESC, deployment_id DESC
|
||||
LIMIT {RetainedDeployments}
|
||||
)
|
||||
""",
|
||||
new { ClusterId = clusterId, DeploymentId = deploymentId },
|
||||
ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this exact artifact is already cached <b>and readable</b> for the cluster, so
|
||||
/// storing it again would rewrite every row to its current value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> Every write to a replicated table fires a capture trigger and
|
||||
/// mints an oplog row. Re-caching an artifact the node already holds — which is what a
|
||||
/// restart's boot-from-cache and every <c>RestoreApplied</c> recovery does — would
|
||||
/// therefore cost a delete plus an insert per chunk plus a pointer update, all carrying
|
||||
/// values identical to what is already there. On a replicating pair that is pure churn;
|
||||
/// on an unpaired (default-OFF) node there is no peer to ack those rows, so they
|
||||
/// accumulate in the oplog until the library's age cap prunes them. The Phase 1 live gate
|
||||
/// saw exactly that (check 8).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Identity is over the bytes, not the ids.</b> The pointer must match on
|
||||
/// <c>artifact_sha256</c> as well as the deployment id and revision hash: a re-composed
|
||||
/// artifact can legitimately carry the same ids with different bytes, and keeping the
|
||||
/// stale copy would serve a boot-from-cache node the wrong address space while every
|
||||
/// id-shaped signal claimed it was right.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The chunk count is load-bearing.</b> A pointer can name a deployment whose chunks
|
||||
/// are missing or partial — a half-replicated artifact, or a prune that raced the
|
||||
/// pointer. Skipping on the pointer alone would make that state permanent, because the
|
||||
/// re-store that repairs it is the call being skipped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Accepted consequence:</b> a skipped store leaves <c>applied_at_utc</c> at the
|
||||
/// moment the artifact was <i>first</i> cached rather than last confirmed. Refreshing it
|
||||
/// would mint the very oplog row this check exists to avoid. Nothing reads it as a
|
||||
/// liveness signal; retention ranks distinct deployments, which are unaffected.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<bool> IsAlreadyCachedAsync(
|
||||
string clusterId, string deploymentId, string revisionHash, string sha, int chunkCount,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var matches = await _db.QueryAsync(
|
||||
"""
|
||||
SELECT (
|
||||
SELECT COUNT(*)
|
||||
FROM deployment_artifacts
|
||||
WHERE cluster_id = @ClusterId
|
||||
AND deployment_id = @DeploymentId
|
||||
AND chunk_count = @ChunkCount
|
||||
)
|
||||
FROM deployment_pointer
|
||||
WHERE cluster_id = @ClusterId
|
||||
AND deployment_id = @DeploymentId
|
||||
AND revision_hash = @RevisionHash
|
||||
AND artifact_sha256 = @Sha
|
||||
""",
|
||||
r => r.GetInt32(0),
|
||||
new
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
DeploymentId = deploymentId,
|
||||
RevisionHash = revisionHash,
|
||||
Sha = sha,
|
||||
ChunkCount = chunkCount,
|
||||
},
|
||||
ct);
|
||||
|
||||
return matches.Count == 1 && matches[0] == chunkCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CachedDeploymentArtifact?> GetCurrentAsync(
|
||||
string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
|
||||
var pointers = await _db.QueryAsync(
|
||||
"""
|
||||
SELECT deployment_id, revision_hash, artifact_sha256, applied_at_utc
|
||||
FROM deployment_pointer
|
||||
WHERE cluster_id = @ClusterId
|
||||
""",
|
||||
ReadPointer,
|
||||
new { ClusterId = clusterId },
|
||||
ct);
|
||||
|
||||
if (pointers.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No cached deployment pointer for cluster {ClusterId}.", clusterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ReassembleAsync(clusterId, pointers[0], ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
|
||||
{
|
||||
// applied_at_utc is round-trip ISO-8601 UTC, so ordering it as TEXT is chronological —
|
||||
// that is the reason the format is pinned rather than left to the current culture.
|
||||
var pointers = await _db.QueryAsync(
|
||||
"""
|
||||
SELECT cluster_id, deployment_id, revision_hash, artifact_sha256, applied_at_utc
|
||||
FROM deployment_pointer
|
||||
ORDER BY applied_at_utc DESC
|
||||
""",
|
||||
r => (ClusterId: r.GetString(0), Pointer: new PointerRow(
|
||||
DeploymentId: r.GetString(1),
|
||||
RevisionHash: r.GetString(2),
|
||||
ArtifactSha256: r.GetString(3),
|
||||
AppliedAtUtc: r.GetString(4))),
|
||||
parameters: null,
|
||||
ct);
|
||||
|
||||
if (pointers.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No cached deployment pointer of any cluster.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pointers.Count > 1)
|
||||
{
|
||||
// The node was re-homed between clusters. Booting the newest is the only defensible
|
||||
// choice, but it must never be a silent one — a wrong-cluster address space presents as
|
||||
// a plausible configuration, so the operator needs the cluster names to spot it.
|
||||
_logger.LogWarning(
|
||||
"Local deployment cache holds pointers for {PointerCount} clusters ({ClusterIds}); " +
|
||||
"booting the newest ({ChosenClusterId}). This node appears to have been re-homed — " +
|
||||
"clear the stale cache if that is unexpected.",
|
||||
pointers.Count,
|
||||
string.Join(", ", pointers.Select(p => p.ClusterId)),
|
||||
pointers[0].ClusterId);
|
||||
}
|
||||
|
||||
return await ReassembleAsync(pointers[0].ClusterId, pointers[0].Pointer, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the artifact the pointer names, returning <see langword="null"/> unless every
|
||||
/// integrity check passes.
|
||||
/// </summary>
|
||||
private async Task<CachedDeploymentArtifact?> ReassembleAsync(
|
||||
string clusterId, PointerRow pointer, CancellationToken ct)
|
||||
{
|
||||
var chunks = await _db.QueryAsync(
|
||||
"""
|
||||
SELECT chunk_count, chunk_base64
|
||||
FROM deployment_artifacts
|
||||
WHERE deployment_id = @DeploymentId
|
||||
ORDER BY chunk_index
|
||||
""",
|
||||
r => (ChunkCount: r.GetInt32(0), Base64: r.GetString(1)),
|
||||
new { pointer.DeploymentId },
|
||||
ct);
|
||||
|
||||
// The chunk_count carried on every row is what makes a partial replica detectable. Without
|
||||
// it a missing tail chunk is indistinguishable from a shorter artifact.
|
||||
var expectedChunkCount = chunks.Count > 0 ? chunks[0].ChunkCount : 0;
|
||||
|
||||
if (chunks.Count != expectedChunkCount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cached deployment {DeploymentId} for cluster {ClusterId} is incomplete: found " +
|
||||
"{FoundChunks} of {ExpectedChunks} chunks. Treating as a cache miss.",
|
||||
pointer.DeploymentId, clusterId, chunks.Count, expectedChunkCount);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] artifact;
|
||||
try
|
||||
{
|
||||
artifact = Decode(chunks.Select(c => c.Base64));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Cached deployment {DeploymentId} for cluster {ClusterId} has a chunk that is not " +
|
||||
"valid base64. Treating as a cache miss.",
|
||||
pointer.DeploymentId, clusterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var actualSha = Convert.ToHexString(SHA256.HashData(artifact));
|
||||
if (!string.Equals(actualSha, pointer.ArtifactSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cached deployment {DeploymentId} for cluster {ClusterId} failed its SHA-256 check " +
|
||||
"(expected {ExpectedSha}, computed {ActualSha}). Treating as a cache miss.",
|
||||
pointer.DeploymentId, clusterId, pointer.ArtifactSha256, actualSha);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(pointer.AppliedAtUtc, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var appliedAtUtc))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cached deployment {DeploymentId} for cluster {ClusterId} has an unparseable " +
|
||||
"applied_at_utc ({AppliedAtUtc}). Treating as a cache miss.",
|
||||
pointer.DeploymentId, clusterId, pointer.AppliedAtUtc);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CachedDeploymentArtifact(
|
||||
pointer.DeploymentId, pointer.RevisionHash, artifact, appliedAtUtc);
|
||||
}
|
||||
|
||||
private static byte[] Decode(IEnumerable<string> chunksInOrder)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
|
||||
foreach (var chunk in chunksInOrder)
|
||||
{
|
||||
var bytes = Convert.FromBase64String(chunk);
|
||||
buffer.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static PointerRow ReadPointer(Microsoft.Data.Sqlite.SqliteDataReader reader)
|
||||
=> new(
|
||||
DeploymentId: reader.GetString(0),
|
||||
RevisionHash: reader.GetString(1),
|
||||
ArtifactSha256: reader.GetString(2),
|
||||
AppliedAtUtc: reader.GetString(3));
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip ("O") UTC, so lexicographic ordering of the stored TEXT is chronological
|
||||
/// ordering — the retention query sorts on it directly.
|
||||
/// </summary>
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private sealed record PointerRow(
|
||||
string DeploymentId, string RevisionHash, string ArtifactSha256, string AppliedAtUtc);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct)
|
||||
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{
|
||||
var driverHost = _resolveDriverHost();
|
||||
if (driverHost is null)
|
||||
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
try
|
||||
{
|
||||
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
||||
new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false);
|
||||
new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
|
||||
return new NodeWriteOutcome(result.Success, result.Reason);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
|
||||
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
|
||||
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
|
||||
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
|
||||
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
|
||||
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
|
||||
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
|
||||
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
|
||||
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
|
||||
/// </summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
|
||||
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
|
||||
BuildRawAndUnsSubtrees(JsonElement root)
|
||||
{
|
||||
var rawFolders = new List<RawFolder>();
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
{
|
||||
var id = ReadString(el, "RawFolderId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
rawFolders.Add(new RawFolder
|
||||
{
|
||||
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
|
||||
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
var driverInstances = new List<DriverInstance>();
|
||||
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||
{
|
||||
var id = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
driverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
|
||||
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
|
||||
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
var devices = new List<Device>();
|
||||
foreach (var el in EnumerateArray(root, "Devices"))
|
||||
{
|
||||
var id = ReadString(el, "DeviceId");
|
||||
var di = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||
devices.Add(new Device
|
||||
{
|
||||
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
|
||||
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
|
||||
});
|
||||
}
|
||||
|
||||
var tagGroups = new List<TagGroup>();
|
||||
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||
{
|
||||
var id = ReadString(el, "TagGroupId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
tagGroups.Add(new TagGroup
|
||||
{
|
||||
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
|
||||
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
|
||||
});
|
||||
}
|
||||
|
||||
var tags = new List<Tag>();
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var id = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var name = ReadString(el, "Name");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
|
||||
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
|
||||
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
|
||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString() ?? "{}" : "{}";
|
||||
tags.Add(new Tag
|
||||
{
|
||||
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
|
||||
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
|
||||
AccessLevel = accessLevel, TagConfig = tagConfig,
|
||||
});
|
||||
}
|
||||
|
||||
var unsTagReferences = new List<UnsTagReference>();
|
||||
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||
{
|
||||
var id = ReadString(el, "UnsTagReferenceId");
|
||||
var equipmentId = ReadString(el, "EquipmentId");
|
||||
var tagId = ReadString(el, "TagId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
|
||||
unsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
|
||||
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
|
||||
});
|
||||
}
|
||||
|
||||
var unsAreas = new List<UnsArea>();
|
||||
foreach (var el in EnumerateArray(root, "UnsAreas"))
|
||||
{
|
||||
var id = ReadString(el, "UnsAreaId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
|
||||
}
|
||||
|
||||
var unsLines = new List<UnsLine>();
|
||||
foreach (var el in EnumerateArray(root, "UnsLines"))
|
||||
{
|
||||
var id = ReadString(el, "UnsLineId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
|
||||
}
|
||||
|
||||
var equipment = new List<Equipment>();
|
||||
foreach (var el in EnumerateArray(root, "Equipment"))
|
||||
{
|
||||
var id = ReadString(el, "EquipmentId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
|
||||
Name = ReadString(el, "Name") ?? id!,
|
||||
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
|
||||
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
|
||||
MachineCode = ReadString(el, "MachineCode") ?? id!,
|
||||
});
|
||||
}
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: unsTagReferences, tags: tags,
|
||||
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
|
||||
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
|
||||
|
||||
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
|
||||
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
|
||||
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
|
||||
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
|
||||
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
|
||||
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
||||
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
|
||||
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
|
||||
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
|
||||
// raw VALUE + UNS reference node is correctly cluster-scoped.
|
||||
RawContainers = full.RawContainers,
|
||||
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
|
||||
/// </param>
|
||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||
public static DiscoveredInjectionPlan Map(
|
||||
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
{
|
||||
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
||||
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
||||
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
||||
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
||||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||||
|
||||
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
||||
@@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper
|
||||
for (var i = 0; i < segs.Count; i++)
|
||||
{
|
||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
||||
var nodeId = Combine(rootNodeId, folderPath);
|
||||
if (folders.ContainsKey(nodeId)) continue;
|
||||
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
|
||||
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
||||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||||
}
|
||||
|
||||
var varFolderPath = string.Join('/', segs);
|
||||
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
|
||||
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
|
||||
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
|
||||
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
|
||||
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||
? Combine(rootNodeId, n.BrowseName)
|
||||
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||
// A folder-less variable parents directly at the root device node.
|
||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||
? equipmentId
|
||||
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
|
||||
? rootNodeId
|
||||
: Combine(rootNodeId, varFolderPath);
|
||||
variables.Add(new DiscoveredVariable(
|
||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||
routing[n.FullReference] = varNodeId;
|
||||
|
||||
@@ -23,6 +23,7 @@ using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
||||
@@ -57,6 +58,31 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <summary>Publishing interval handed to each driver's SubscribeBulk pass after an apply.</summary>
|
||||
private static readonly TimeSpan SubscriptionPublishingInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Cache key used when an artifact carries no cluster scoping (single-cluster or unscoped
|
||||
/// deployments). A literal rather than an empty string so the row is visibly deliberate when
|
||||
/// someone reads the table during an incident.
|
||||
/// </summary>
|
||||
private const string SingleClusterCacheKey = "__single";
|
||||
|
||||
/// <summary>
|
||||
/// Node-local cache of applied deployment artifacts, or null when this node has none
|
||||
/// (admin-only graphs, and tests that do not exercise it).
|
||||
/// </summary>
|
||||
private readonly IDeploymentArtifactCache? _deploymentArtifactCache;
|
||||
|
||||
/// <summary>
|
||||
/// True once this node has booted a cached artifact because central SQL was unreachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Surfaced on <see cref="NodeDiagnosticsSnapshot"/> because a node running from cache looks
|
||||
/// completely healthy from the outside — it serves a full address space with live values.
|
||||
/// The difference is that its configuration is frozen at whatever the cache held, and no
|
||||
/// deployment can reach it. Without an explicit signal that is invisible until someone
|
||||
/// wonders why a deploy "succeeded" everywhere but did not take effect here.
|
||||
/// </remarks>
|
||||
private bool _isRunningFromCache;
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly CommonsNodeId _localNode;
|
||||
private readonly IActorRef? _coordinatorOverride;
|
||||
@@ -111,34 +137,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// spawn so a restart's respawn never collides with the still-terminating old child.
|
||||
private long _childSpawnGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment
|
||||
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and
|
||||
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands
|
||||
/// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back
|
||||
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
||||
/// rebuild dedups by NodeId.
|
||||
/// </summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
||||
/// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
|
||||
/// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
|
||||
/// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
|
||||
/// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
|
||||
private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
||||
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/>
|
||||
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an
|
||||
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning
|
||||
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver
|
||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
||||
/// map fans out 1:N because one ref can back several variables).
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
|
||||
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
|
||||
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
|
||||
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
|
||||
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
|
||||
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
|
||||
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
|
||||
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
|
||||
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
|
||||
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
|
||||
/// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||
/// composition's <c>RawTags</c> ∪ <c>UnsReferenceVariables</c>, and resolved by
|
||||
/// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
|
||||
/// owning driver child as a write of its wire-ref RawPath.
|
||||
/// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=<RawPath></c> and a UNS
|
||||
/// <c>s=<Area/Line/Equip/Eff></c> can collide as bare strings (folder/driver/device names and
|
||||
/// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route
|
||||
/// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full
|
||||
/// ns-qualified id PLUS the realm it resolved (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
|
||||
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
|
||||
/// namespace disambiguation the ns-qualified id carries.</para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
|
||||
|
||||
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
|
||||
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
|
||||
/// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
|
||||
/// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
|
||||
/// value-subscription set.</summary>
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
||||
@@ -159,7 +197,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <c>TagConfig.alarm.historizeToAveva</c>; it is threaded onto the transition so the
|
||||
/// HistorianAdapterActor's <c>is not false</c> gate suppresses the durable AVEVA row only on an
|
||||
/// explicit false (mirroring the scripted-alarm opt-out).</summary>
|
||||
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva)> _alarmMetaByNodeId =
|
||||
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths)> _alarmMetaByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
|
||||
@@ -232,7 +270,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
|
||||
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
|
||||
public sealed record RouteNodeWrite(string NodeId, object? Value);
|
||||
public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
|
||||
/// (or a gate/lookup failure that never reached the driver).</summary>
|
||||
@@ -327,11 +365,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
Func<int>? driverMemberCountProvider = null) =>
|
||||
Func<int>? driverMemberCountProvider = null,
|
||||
IDeploymentArtifactCache? deploymentArtifactCache = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
// the wrong dependency at runtime. New parameters go LAST in all three places — this
|
||||
// signature, the constructor's, and this call — and nothing else moves.
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider));
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||
deploymentArtifactCache));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -360,6 +405,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <param name="driverMemberCountProvider">Test seam (archreview 03/S4): overrides the count of Up
|
||||
/// <c>driver</c>-role cluster members the Primary gate reads while the role is unknown. When null the
|
||||
/// default reads <c>Cluster.Get(Context.System).State.Members</c> (0 on a non-cluster ActorRefProvider).</param>
|
||||
/// <param name="deploymentArtifactCache">Optional node-local cache of applied deployment artifacts.
|
||||
/// 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
|
||||
/// tests that do not exercise the cache — caching is then simply skipped.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -376,8 +425,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null,
|
||||
Func<int>? driverMemberCountProvider = null)
|
||||
Func<int>? driverMemberCountProvider = null,
|
||||
IDeploymentArtifactCache? deploymentArtifactCache = null)
|
||||
{
|
||||
_deploymentArtifactCache = deploymentArtifactCache;
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
_coordinatorOverride = coordinator;
|
||||
@@ -529,7 +580,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// space were lost on restart. Re-spawn + re-materialise + re-subscribe from the
|
||||
// applied deployment so a restarted/rebuilt node restores its served state instead
|
||||
// of waiting for a config change (whose identical-config revision would no-op).
|
||||
RestoreApplied(new DeploymentId(latest.DeploymentId));
|
||||
RestoreApplied(new DeploymentId(latest.DeploymentId), revision);
|
||||
break;
|
||||
case NodeDeploymentStatus.Applying:
|
||||
_log.Warning("DriverHost {Node}: found orphan Applying row for deployment {Id}; replaying",
|
||||
@@ -548,16 +599,129 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: ConfigDb unreachable on bootstrap; entering Stale", _localNode);
|
||||
Become(Stale);
|
||||
|
||||
// Central is unreachable, so try the node-local cache before giving up. On a hit this
|
||||
// node serves its last-known-good configuration through the outage instead of coming up
|
||||
// with an empty address space. On a miss, behaviour is exactly what it always was.
|
||||
if (!TryBootFromCache())
|
||||
Become(Stale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last-resort boot path: apply the artifact cached by a previous successful deploy (or
|
||||
/// replicated from this node's pair peer) when central SQL cannot be reached.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when a cached artifact was applied and the actor is now Steady;
|
||||
/// <see langword="false"/> when the caller should fall through to <c>Stale</c>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The read is unkeyed.</b> The cache is keyed by ClusterId so a pair shares one
|
||||
/// entry, but ClusterId is only derivable from an artifact you already hold or from the
|
||||
/// central DB — and at this seam we have neither. So the newest pointer row wins, which
|
||||
/// is correct in every real topology because a node belongs to one cluster and its peer
|
||||
/// replicates that same cluster's row. A node re-homed between clusters is the only
|
||||
/// ambiguous case, and the cache logs a warning naming both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This does not make the node current.</b> <c>_currentRevision</c> is set from the
|
||||
/// cached artifact, so a subsequent dispatch of that same revision correctly no-ops,
|
||||
/// while any NEW revision still requires central — the cache holds the past, not the
|
||||
/// future. Dispatch handling is unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Never throws: a fault here must degrade to Stale, which is exactly where the node
|
||||
/// would have been without a cache at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool TryBootFromCache()
|
||||
{
|
||||
if (_deploymentArtifactCache is null)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var cached = _deploymentArtifactCache.GetCurrentUnkeyedAsync().GetAwaiter().GetResult();
|
||||
if (cached is null)
|
||||
{
|
||||
_log.Info(
|
||||
"DriverHost {Node}: no cached deployment available; entering Stale with no configuration.",
|
||||
_localNode);
|
||||
return false;
|
||||
}
|
||||
|
||||
var deploymentId = DeploymentId.Parse(cached.DeploymentId);
|
||||
var revision = RevisionHash.Parse(cached.RevisionHash);
|
||||
|
||||
// Steady, not Stale: this node is serving a real configuration. The retry-db timer that
|
||||
// Stale would have started does not run here, so recovery rides on the next dispatch —
|
||||
// matching how a normally-booted node behaves.
|
||||
_currentRevision = revision;
|
||||
Become(Steady);
|
||||
|
||||
ApplyCachedArtifact(deploymentId, cached.Artifact);
|
||||
|
||||
_isRunningFromCache = true;
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: RUNNING FROM CACHE — central ConfigDb is unreachable, so this node " +
|
||||
"booted deployment {Id} (rev {Rev}) cached at {CachedAtUtc:o} from its local database. " +
|
||||
"Configuration changes cannot be applied until the ConfigDb is reachable again.",
|
||||
_localNode, deploymentId, revision, cached.AppliedAtUtc);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex,
|
||||
"DriverHost {Node}: failed to boot from the local deployment cache; falling back to Stale.",
|
||||
_localNode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an artifact already in hand — no ConfigDb read, no ACK.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="RestoreApplied"/>, but sources the artifact from the cache rather than
|
||||
/// re-reading it from a database that is by definition unreachable here. It also skips
|
||||
/// <c>UpsertNodeDeploymentState</c> and <c>SendAck</c> for the same reason: both write to or
|
||||
/// depend on central.
|
||||
/// </remarks>
|
||||
private void ApplyCachedArtifact(DeploymentId deploymentId, byte[] blob)
|
||||
{
|
||||
var correlation = CorrelationId.NewId();
|
||||
|
||||
var specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
|
||||
var snapshots = _children.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson, kv.Value.ResilienceConfig),
|
||||
StringComparer.Ordinal);
|
||||
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
||||
|
||||
foreach (var id in plan.ToStop) StopChild(id);
|
||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||
|
||||
// Hand the cached blob to the rebuild so it materialises the client-facing address space from
|
||||
// it directly. Passing only the deploymentId would drive a ConfigDb read — unreachable here by
|
||||
// definition — and the rebuild would no-op, leaving OPC UA clients browsing an empty server
|
||||
// while the drivers below poll happily. (Found on the docker-dev live gate.)
|
||||
_opcUaPublishActor?.Tell(
|
||||
new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId, blob));
|
||||
|
||||
PushDesiredSubscriptionsFromArtifact(deploymentId, blob);
|
||||
}
|
||||
|
||||
private void Steady()
|
||||
{
|
||||
Receive<DispatchDeployment>(HandleDispatchFromSteady);
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -588,6 +752,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -620,13 +785,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// equipment variables (identical machines sharing a register), hence the fan-out.
|
||||
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
|
||||
{
|
||||
foreach (var nodeId in nodeIds)
|
||||
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
|
||||
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
|
||||
foreach (var n in nodeIds)
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
|
||||
nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
|
||||
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped",
|
||||
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
|
||||
_localNode, msg.DriverInstanceId, msg.FullReference);
|
||||
}
|
||||
}
|
||||
@@ -643,6 +810,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
|
||||
{
|
||||
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
|
||||
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
|
||||
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
|
||||
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
|
||||
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
|
||||
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
|
||||
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
|
||||
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
|
||||
// is a separate follow-up.
|
||||
_log.Debug(
|
||||
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
|
||||
_localNode, msg.DriverInstanceId);
|
||||
return;
|
||||
|
||||
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
|
||||
if (_lastComposition is null)
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
|
||||
@@ -717,6 +899,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -918,9 +1101,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var key = (driverId, driverRef);
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
|
||||
// through the Raw realm.
|
||||
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
|
||||
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
|
||||
}
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
|
||||
equipmentId, plan.Folders, plan.Variables));
|
||||
@@ -978,6 +1163,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
|
||||
/// alarm condition the driver owns: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>. This is the ONLY signal for a comms-lost native source, because a
|
||||
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
|
||||
/// comms-lost condition would keep reporting the accidentally-Good default forever.
|
||||
/// <para>
|
||||
/// UNGATED by redundancy role (like the condition write in <see cref="ForwardNativeAlarm"/>): a
|
||||
/// Secondary keeps its address space — including condition quality — warm for failover. Quality is
|
||||
/// a pure annotation: <c>WriteAlarmQuality</c> touches ONLY the condition's Quality, never its
|
||||
/// Active/Acked/Retain, and fires a Part 9 event only on a real quality-bucket change. No cluster
|
||||
/// <c>alerts</c> row is published here — driver comms health has its own status surface
|
||||
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
|
||||
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
|
||||
var ts = DateTime.UtcNow;
|
||||
var annotated = 0;
|
||||
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
|
||||
// (DriverInstanceId, RawPath); one driver ref can back several condition NodeIds (identical machines).
|
||||
foreach (var ((driverId, _), nodeIds) in _alarmNodeIdByDriverRef)
|
||||
{
|
||||
if (driverId != msg.DriverInstanceId) continue;
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
|
||||
n.NodeId, quality, ts, n.Realm));
|
||||
annotated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (annotated > 0)
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) {Quality}",
|
||||
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, quality);
|
||||
}
|
||||
|
||||
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
@@ -1005,17 +1230,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||
}
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
var nodeId = n.NodeId;
|
||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc));
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null);
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||
ReferencingEquipmentPaths: (IReadOnlyList<string>)Array.Empty<string>());
|
||||
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
|
||||
AlarmId: nodeId,
|
||||
EquipmentPath: meta.EquipmentId,
|
||||
@@ -1035,7 +1262,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
||||
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
||||
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
||||
HistorizeToAveva: meta.HistorizeToAveva)));
|
||||
HistorizeToAveva: meta.HistorizeToAveva,
|
||||
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
|
||||
// renders them as the equipment list (empty for a raw condition no equipment references yet).
|
||||
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,9 +1317,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target))
|
||||
// v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string
|
||||
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>") PLUS the realm it resolved from the namespace index.
|
||||
// The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look
|
||||
// up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a
|
||||
// bare string, so a write always routes to its OWN driver ref (never a colliding sibling's).
|
||||
var bareNodeId = BareNodeId(msg.NodeId);
|
||||
if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target))
|
||||
{
|
||||
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}"));
|
||||
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1115,6 +1351,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
.PipeTo(replyTo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalise an OPC UA NodeId string to its BARE <c>s=</c> identifier. The node manager's inbound-write
|
||||
/// hook passes the full ns-qualified form (<c>NodeId.ToString()</c> == <c>"ns=<N>;s=<id>"</c>);
|
||||
/// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes
|
||||
/// <c>"ns=N;s="</c> for a string identifier in a non-zero namespace, so the first <c>";s="</c> is the
|
||||
/// delimiter (an id may itself contain <c>";s="</c> later — <c>IndexOf</c> takes the FIRST, which is the
|
||||
/// namespace delimiter). A bare id (no prefix) passes through unchanged.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The (possibly ns-qualified) NodeId string.</param>
|
||||
/// <returns>The bare <c>s=</c> identifier.</returns>
|
||||
internal static string BareNodeId(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId)) return nodeId;
|
||||
var i = nodeId.IndexOf(";s=", StringComparison.Ordinal);
|
||||
if (i >= 0) return nodeId[(i + 3)..];
|
||||
if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..];
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
|
||||
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
|
||||
@@ -1264,6 +1519,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
||||
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
@@ -1286,7 +1544,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
NodeId: _localNode,
|
||||
CurrentRevision: _currentRevision,
|
||||
Drivers: drivers,
|
||||
AsOfUtc: DateTime.UtcNow);
|
||||
AsOfUtc: DateTime.UtcNow,
|
||||
RunningFromCache: _isRunningFromCache);
|
||||
Sender.Tell(snapshot);
|
||||
}
|
||||
|
||||
@@ -1319,7 +1578,30 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
try
|
||||
{
|
||||
ReconcileDrivers(deploymentId);
|
||||
var appliedBlob = ReconcileDrivers(deploymentId);
|
||||
|
||||
// Issue #486 — a null blob means the artifact could not be read (unreachable ConfigDb, or the
|
||||
// empty bytes a missing row yields), so NOTHING was applied. Reporting Applied here used to
|
||||
// advance _currentRevision too, and HandleDispatchFromSteady short-circuits on a revision
|
||||
// match — so the node claimed a configuration it never applied AND could never be healed by
|
||||
// re-dispatching that revision. Failing the apply keeps the revision where it was, which is
|
||||
// what makes the retry land instead of being waved through. The node keeps serving its
|
||||
// last-known-good address space, drivers and subscriptions throughout (issue #485) — this is
|
||||
// about telling the truth, not about tearing anything down.
|
||||
if (appliedBlob is null)
|
||||
{
|
||||
const string reason =
|
||||
"the deployment artifact could not be read (ConfigDb unreachable, or the row is missing/empty); nothing was applied";
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Failed, reason);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Failed, reason, correlation);
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
|
||||
span?.SetStatus(ActivityStatusCode.Error, reason);
|
||||
_log.Error(
|
||||
"DriverHost {Node}: apply of {Id} FAILED — {Reason}. Staying on revision {Rev} and continuing to serve it; re-dispatch once the ConfigDb is readable",
|
||||
_localNode, deploymentId, reason, _currentRevision);
|
||||
return;
|
||||
}
|
||||
|
||||
_currentRevision = revision;
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
||||
@@ -1330,6 +1612,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// SubscribeBulk pass: hand each driver its desired tag references so live values flow into
|
||||
// the just-rebuilt address space instead of staying BadWaitingForInitialData.
|
||||
PushDesiredSubscriptions(deploymentId);
|
||||
CacheAppliedArtifact(deploymentId, revision, appliedBlob);
|
||||
// Reaching here means the artifact came from central, so the node is no longer serving a
|
||||
// cache-sourced configuration. Note this only clears on a real apply: a dispatch of the
|
||||
// revision already booted from cache short-circuits in HandleDispatchFromSteady without
|
||||
// touching the ConfigDb, and the flag correctly stays set.
|
||||
_isRunningFromCache = false;
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "ack"));
|
||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
|
||||
_localNode, deploymentId, revision, _children.Count);
|
||||
@@ -1353,11 +1641,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <summary>
|
||||
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
||||
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
|
||||
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) or the
|
||||
/// configured <see cref="IDriverFactory"/> can't materialise any of the requested
|
||||
/// types, this is effectively a no-op.
|
||||
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
|
||||
/// SKIPPED outright — see the issue #485 guard below — and when the configured
|
||||
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
|
||||
/// </summary>
|
||||
private void ReconcileDrivers(DeploymentId deploymentId)
|
||||
/// <returns>
|
||||
/// The artifact blob that was reconciled, or <see langword="null"/> when it could not be
|
||||
/// loaded at all.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Returning the blob rather than swallowing it is load-bearing for the artifact cache. This
|
||||
/// method catches its own DB failures, logs a warning and returns WITHOUT rethrowing — so
|
||||
/// <see cref="ApplyAndAck"/> proceeds to its success path, ACKs Applied and logs success
|
||||
/// having spawned zero drivers. A cache write that trusted "the apply succeeded" would then
|
||||
/// persist an empty artifact as this node's last-known-good configuration, and the node would
|
||||
/// later boot from it into an empty address space. The caller distinguishes the two cases by
|
||||
/// this return value.
|
||||
/// </remarks>
|
||||
private byte[]? ReconcileDrivers(DeploymentId deploymentId)
|
||||
{
|
||||
byte[] blob;
|
||||
try
|
||||
@@ -1372,7 +1673,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: failed to load artifact for {Id}; skipping reconcile",
|
||||
_localNode, deploymentId);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Issue #485 — no bytes is no answer, not "a configuration with no drivers". Parsing zero bytes
|
||||
// yields zero specs, and DriverSpawnPlanner then plans EVERY running child for StopChild: a missing
|
||||
// deployment row (or a half-written one) silently takes this node's entire field I/O down while the
|
||||
// apply still ACKs Applied. Nothing legitimate produces a zero-length blob — deleting the last
|
||||
// driver still deploys a JSON document with empty arrays — so treat it exactly like the load
|
||||
// failure above and leave the running children alone.
|
||||
if (blob.Length == 0)
|
||||
{
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: artifact for {Id} read back empty; skipping reconcile and keeping the {Count} running driver(s)",
|
||||
_localNode, deploymentId, _children.Count);
|
||||
return null;
|
||||
}
|
||||
|
||||
var specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
|
||||
@@ -1393,6 +1708,74 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// sequenced AFTER ReinitializeAsync rebuilds DependencyRefs — a synchronous re-register HERE would
|
||||
// re-read the STALE ref set (ApplyDelta runs asynchronously on the child), so it is deliberately NOT
|
||||
// done inline.
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store a successfully applied artifact in the node-local cache, so this node can boot from
|
||||
/// it while central SQL is unreachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Never throws.</b> By the time this runs the deployment is already recorded Applied
|
||||
/// in central SQL and an Applied ACK has been sent to the coordinator. An exception
|
||||
/// escaping here would unwind into <see cref="ApplyAndAck"/>'s catch and send a second,
|
||||
/// contradictory Failed ACK for a deployment the fleet already believes is live.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An empty or unloadable blob is skipped, not cached.</b> See
|
||||
/// <see cref="ReconcileDrivers"/>: a DB failure there degrades silently, so "the apply
|
||||
/// succeeded" does not imply "a real configuration was applied". Caching an empty
|
||||
/// artifact would make it this node's last-known-good and boot it into an empty address
|
||||
/// space during the next outage — strictly worse than having no cache at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs synchronously on the actor thread. That matches every other DB call on this path
|
||||
/// (all of which already block), and the alternative — piping the result back as a
|
||||
/// self-message — would need a handler registered in Steady, Applying AND Stale or it
|
||||
/// dead-letters after the <c>Become(Steady)</c> in the enclosing finally.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void CacheAppliedArtifact(DeploymentId deploymentId, RevisionHash revision, byte[]? blob)
|
||||
{
|
||||
if (_deploymentArtifactCache is null)
|
||||
return;
|
||||
|
||||
if (blob is null || blob.Length == 0)
|
||||
{
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: not caching deployment {Id} — its artifact was empty or could not " +
|
||||
"be loaded, so it is not a usable last-known-good configuration.",
|
||||
_localNode, deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The node's ClusterId is carried inside the artifact (ClusterNode rows), not in config
|
||||
// or on IClusterRoleInfo. A single-cluster or unscoped artifact has none, so it shares
|
||||
// one sentinel key — the pair still converges on a single pointer row either way.
|
||||
var scope = DeploymentArtifact.ResolveClusterScope(blob, _localNode.Value);
|
||||
var clusterId = scope.Mode == ClusterFilterMode.ScopeTo && !string.IsNullOrWhiteSpace(scope.ClusterId)
|
||||
? scope.ClusterId
|
||||
: SingleClusterCacheKey;
|
||||
|
||||
_deploymentArtifactCache
|
||||
.StoreAsync(clusterId, deploymentId.ToString(), revision.Value, blob)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
_log.Debug("DriverHost {Node}: cached deployment {Id} (rev {Rev}, cluster {ClusterId}, {Bytes} bytes)",
|
||||
_localNode, deploymentId, revision, clusterId, blob.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex,
|
||||
"DriverHost {Node}: failed to cache applied deployment {Id}. The apply itself succeeded " +
|
||||
"and is unaffected; this node simply has no local fallback for that revision.",
|
||||
_localNode, deploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1404,14 +1787,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// drivers, rebuilds the address space from the applied artifact, and re-pushes SubscribeBulk.
|
||||
/// No re-ack: the deployment is already Applied.
|
||||
/// </summary>
|
||||
private void RestoreApplied(DeploymentId deploymentId)
|
||||
private void RestoreApplied(DeploymentId deploymentId, RevisionHash? revision)
|
||||
{
|
||||
var correlation = CorrelationId.NewId();
|
||||
try
|
||||
{
|
||||
ReconcileDrivers(deploymentId);
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId));
|
||||
var appliedBlob = ReconcileDrivers(deploymentId);
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId, appliedBlob));
|
||||
PushDesiredSubscriptions(deploymentId);
|
||||
// Populate the node-local cache from the artifact this restored node is now serving. The
|
||||
// cache invariant is "holds what the node currently serves"; without this only a FRESH
|
||||
// apply (ApplyAndAck) ever wrote it, so a node whose cache was lost — a wiped/fresh volume,
|
||||
// a disk failure — would recover its served state here yet stay cache-less, unable to
|
||||
// boot-from-cache on the NEXT central outage until some future new deploy happened to land.
|
||||
// Replication does not heal that gap either: a peer's already-acked rows are pruned from its
|
||||
// oplog, so a fully-wiped node is never back-filled. Re-caching on restore is what closes
|
||||
// it. (Found on the docker-dev live gate.)
|
||||
if (revision is { } rev)
|
||||
CacheAppliedArtifact(deploymentId, rev, appliedBlob);
|
||||
_log.Info("DriverHost {Node}: restored served state for applied deployment {Id} on bootstrap", _localNode, deploymentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1445,6 +1838,31 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
PushDesiredSubscriptionsFromArtifact(deploymentId, blob);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The artifact-processing half of <see cref="PushDesiredSubscriptions"/>, split out so the
|
||||
/// boot-from-cache path can drive it with bytes already in hand.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separated because the cache path runs precisely when the ConfigDb read above cannot
|
||||
/// succeed — re-reading the artifact there would fail by definition.
|
||||
/// </remarks>
|
||||
private void PushDesiredSubscriptionsFromArtifact(DeploymentId deploymentId, byte[] blob)
|
||||
{
|
||||
// Issue #485 — the same "no bytes is no answer" rule as ReconcileDrivers. An empty artifact parses
|
||||
// to a composition with no raw tags, which hands every child an EMPTY desired set — and an empty set
|
||||
// drops the live subscription handle rather than re-subscribing. Reachable independently of the
|
||||
// reconcile guard: this method does its OWN ConfigDb read, so the row can go missing between the two.
|
||||
if (blob.Length == 0)
|
||||
{
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: artifact for {Id} read back empty; skipping SubscribeBulk and keeping the live subscriptions",
|
||||
_localNode, deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
AddressSpaceComposition composition;
|
||||
try
|
||||
{
|
||||
@@ -1456,89 +1874,89 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they
|
||||
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native
|
||||
// alarm event stream, routed by ForwardNativeAlarm).
|
||||
var refsByDriver = composition.EquipmentTags
|
||||
// v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
|
||||
// per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
|
||||
// raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
|
||||
// excluded from the value set.
|
||||
var refsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's
|
||||
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one
|
||||
// alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the
|
||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by
|
||||
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.EquipmentTags
|
||||
// Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
|
||||
// An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
|
||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
|
||||
// in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is not null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors
|
||||
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to
|
||||
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux
|
||||
// can land driver values on the right node. Clear-and-repopulate every apply so renames
|
||||
// (Name/FolderPath/EquipmentId changes) and removals are reflected.
|
||||
// Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
|
||||
// every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
|
||||
var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
|
||||
foreach (var v in composition.UnsReferenceVariables)
|
||||
{
|
||||
if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list))
|
||||
unsRefsByRawPath[v.BackingRawPath] = list = new List<UnsReferenceVariable>();
|
||||
list.Add(v);
|
||||
}
|
||||
|
||||
// Rebuild the driver live-value + write routing maps from the RawTags ∪ UnsReferenceVariables pass.
|
||||
// Clear-and-repopulate every apply so renames/removals/re-points are reflected.
|
||||
_nodeIdByDriverRef.Clear();
|
||||
// Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an
|
||||
// operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref
|
||||
// FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are
|
||||
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
|
||||
// attribute), so last-writer-wins on the rare duplicate is harmless.
|
||||
_driverRefByNodeId.Clear();
|
||||
// Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
|
||||
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
|
||||
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
|
||||
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across
|
||||
// redeploys (renames/removals/address-space rebuilds).
|
||||
_alarmNodeIdByDriverRef.Clear();
|
||||
// Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId,
|
||||
// FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver
|
||||
// acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME
|
||||
// alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to
|
||||
// exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the
|
||||
// rare duplicate is harmless.
|
||||
_driverRefByAlarmNodeId.Clear();
|
||||
// Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in
|
||||
// the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it.
|
||||
_alarmMetaByNodeId.Clear();
|
||||
_nativeAlarmProjector.Clear();
|
||||
foreach (var t in composition.EquipmentTags)
|
||||
foreach (var t in composition.RawTags)
|
||||
{
|
||||
var key = (t.DriverInstanceId, t.FullName);
|
||||
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
|
||||
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and
|
||||
// keep them OUT of the value maps + value-subscription set (so they don't get both a value
|
||||
// variable AND a condition).
|
||||
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
|
||||
// equipment notifier NodeIds; WP3 wires the single raw condition.
|
||||
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
|
||||
aset.Add(nodeId);
|
||||
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition
|
||||
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA
|
||||
// acknowledge of this condition reaches the right driver child.
|
||||
_driverRefByAlarmNodeId[nodeId] = key;
|
||||
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
|
||||
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
|
||||
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
|
||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
|
||||
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
|
||||
_driverRefByAlarmNodeId[t.NodeId] = key;
|
||||
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
|
||||
// equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
|
||||
// Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
|
||||
// the /alerts row lists which equipment reference this raw condition.
|
||||
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm)
|
||||
// against the SAME driver ref — the single value source fans to all of them.
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
_driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key;
|
||||
|
||||
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
|
||||
{
|
||||
foreach (var v in refs)
|
||||
{
|
||||
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
|
||||
// A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never
|
||||
// collide with a raw NodeId that shares its bare string.
|
||||
_driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
|
||||
|
||||
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
public sealed record InitializeFailed(string Reason, int Generation);
|
||||
public sealed record DisconnectObserved(string Reason);
|
||||
/// <summary>#477 — sent to the parent (<see cref="DriverHostActor"/>) on every connectivity transition:
|
||||
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
||||
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
|
||||
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
||||
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
/// <summary>
|
||||
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void Connected()
|
||||
{
|
||||
// #477 — announce connectivity to the host so it can clear any comms-lost Quality annotation on this
|
||||
// driver's native alarm conditions (Bad → Good). Fire-and-forget; the host defaults conditions to a
|
||||
// non-Good "waiting for initial data" quality at materialise, and this is what confirms them Good.
|
||||
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: true));
|
||||
|
||||
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
||||
Receive<DisconnectObserved>(msg =>
|
||||
{
|
||||
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void Reconnecting()
|
||||
{
|
||||
// #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad
|
||||
// (a comms-lost driver emits no alarm events, so this is the ONLY signal that the source is unreachable).
|
||||
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
|
||||
|
||||
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
|
||||
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
|
||||
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
|
||||
|
||||
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
|
||||
/// <summary>
|
||||
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
||||
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
||||
/// <see cref="Enabled"/> is <c>true</c>.
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
|
||||
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
|
||||
/// expander resolves fail-closed (throwing if the secret is absent) before this options
|
||||
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public string ApiKey { get; init; } = "";
|
||||
|
||||
|
||||
@@ -39,23 +39,51 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
|
||||
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
|
||||
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
||||
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
|
||||
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
|
||||
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
|
||||
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
|
||||
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
|
||||
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
||||
/// <c>Core.ScriptedAlarms</c>.</summary>
|
||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
|
||||
/// v3 native raw conditions).</param>
|
||||
/// <param name="State">The full condition state to project onto the node.</param>
|
||||
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
|
||||
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
||||
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
|
||||
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
|
||||
/// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change.</summary>
|
||||
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="Quality">The source-data quality to annotate.</param>
|
||||
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
/// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which
|
||||
/// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision).
|
||||
/// </summary>
|
||||
public sealed record RebuildAddressSpace(CorrelationId Correlation, DeploymentId? DeploymentId = null);
|
||||
/// <param name="Correlation">Correlation id for tracing this rebuild.</param>
|
||||
/// <param name="DeploymentId">The applied deployment whose artifact to materialise, or null for the latest-sealed fallback.</param>
|
||||
/// <param name="Artifact">
|
||||
/// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is
|
||||
/// what makes boot-from-cache actually serve its address space: on a central-SQL outage the
|
||||
/// host has the cached blob but <see cref="DeploymentId"/> alone would drive a ConfigDb read
|
||||
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
|
||||
/// on the normal path, where loading from the ConfigDb by id is correct.
|
||||
/// </param>
|
||||
public sealed record RebuildAddressSpace(
|
||||
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
|
||||
|
||||
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
|
||||
/// <param name="EquipmentRootNodeId">The OPC UA NodeId of the equipment root folder to inject the
|
||||
@@ -84,6 +112,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
private int _writes;
|
||||
private byte _lastServiceLevel;
|
||||
private bool _publishedAtLeastOnce;
|
||||
/// <summary>True once a real composition has been applied, i.e. there is a served address space worth
|
||||
/// protecting. Distinguishes "the artifact went missing under a running server" (issue #485 — warn, and
|
||||
/// hold what is materialised) from "nothing has been deployed here yet" (a quiet no-op at boot).</summary>
|
||||
private bool _hasAppliedComposition;
|
||||
private DbHealthProbeActor.DbHealthStatus? _lastDbHealth;
|
||||
private RedundancyStateChanged? _lastSnapshot;
|
||||
private (bool Ok, DateTime At)? _probeAboutMe;
|
||||
@@ -230,6 +262,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
@@ -263,7 +296,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
@@ -277,7 +310,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
@@ -287,6 +320,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
@@ -312,13 +359,37 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
try
|
||||
{
|
||||
// Prefer the artifact of the deployment the host just applied — at apply time it is not
|
||||
// yet Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a
|
||||
// stale composition (variables that don't match the SubscribeBulk refs). Fall back to
|
||||
// latest-sealed only for legacy callers that don't carry a DeploymentId.
|
||||
var artifact = msg.DeploymentId is { } depId
|
||||
? LoadArtifact(depId)
|
||||
: LoadLatestArtifact();
|
||||
// An in-hand artifact (boot-from-cache) wins: the host already has the cached bytes, and
|
||||
// the ConfigDb read the DeploymentId path would do is exactly what is unreachable during
|
||||
// the outage this exists to survive. Otherwise prefer the artifact of the deployment the
|
||||
// host just applied — at apply time it is not yet Sealed, so LoadLatestArtifact would
|
||||
// return the PREVIOUS revision and materialise a stale composition (variables that don't
|
||||
// match the SubscribeBulk refs). Fall back to latest-sealed only for legacy callers that
|
||||
// carry neither.
|
||||
var artifact = msg.Artifact
|
||||
?? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact());
|
||||
|
||||
// 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
|
||||
// PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the
|
||||
// served address space. Nothing legitimate produces a zero-length blob (an operator who really
|
||||
// deletes everything still deploys a JSON document with empty arrays), so the only honest
|
||||
// reading of "no bytes" is "no answer" — hold the last-known-good address space and let the
|
||||
// next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied
|
||||
// intact, so the retry diffs against what is actually materialised.
|
||||
if (artifact is null or { Length: 0 })
|
||||
{
|
||||
if (_hasAppliedComposition)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
else
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
return;
|
||||
}
|
||||
|
||||
var composition = _localNode is { } ln
|
||||
? DeploymentArtifact.ParseComposition(artifact, ln.Value,
|
||||
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
|
||||
@@ -334,12 +405,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
_lastApplied = composition;
|
||||
_hasAppliedComposition = true;
|
||||
|
||||
// Sum swallowed per-node materialise failures across every pass together with Apply's own
|
||||
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
|
||||
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
||||
var failedNodes = outcome.FailedNodes;
|
||||
failedNodes += _applier.MaterialiseHierarchy(composition);
|
||||
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
|
||||
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
|
||||
// edge finds its raw target. The driver binds live values to these raw NodeIds.
|
||||
failedNodes += _applier.MaterialiseRawSubtree(composition);
|
||||
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
|
||||
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
|
||||
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
|
||||
failedNodes += _applier.MaterialiseUnsReferences(composition);
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
@@ -390,7 +470,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
|
||||
/// which may not be Sealed yet). Empty array on any failure — parser treats it as "no composition".</summary>
|
||||
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
|
||||
/// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485).</summary>
|
||||
private byte[] LoadArtifact(DeploymentId deploymentId)
|
||||
{
|
||||
try
|
||||
@@ -403,13 +484,13 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; rebuild becomes no-op", deploymentId);
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
|
||||
/// Empty array on any failure — the parser treats empty blob as "no composition".</summary>
|
||||
/// Empty array on any failure — see <see cref="LoadArtifact"/> for how that is handled.</summary>
|
||||
private byte[] LoadLatestArtifact()
|
||||
{
|
||||
try
|
||||
@@ -423,7 +504,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; rebuild becomes no-op");
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned");
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
|
||||
_upstream.Push(msg.TagId,
|
||||
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
|
||||
}
|
||||
|
||||
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Bad => 0x80000000u,
|
||||
OpcUaQuality.Uncertain => 0x40000000u,
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
|
||||
private void OnEngineEmission(EngineEmission msg)
|
||||
{
|
||||
var e = msg.Event;
|
||||
@@ -294,13 +314,30 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation (worst-of-input bucket moved with no Part 9
|
||||
// state transition — e.g. an input went Bad, freezing the condition). Route it to the dedicated
|
||||
// WriteAlarmQuality node path (sets ONLY Quality, one Part 9 event on a bucket change): NO full-state
|
||||
// projection (would clobber severity/message) and NO /alerts row (it is not a state transition, so it
|
||||
// must not historize). Same out-of-band quality path native comms-loss uses (#477 Layer 2).
|
||||
if (e.Emission == EmissionKind.QualityChanged)
|
||||
{
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode),
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
return;
|
||||
}
|
||||
|
||||
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
||||
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
||||
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
||||
// Scripted alarms are per-equipment condition nodes in the UNS realm.
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
State: ToSnapshot(e),
|
||||
TimestampUtc: e.TimestampUtc));
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
|
||||
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
||||
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
|
||||
@@ -537,7 +574,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message);
|
||||
Message: e.Message,
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
@@ -15,6 +15,7 @@ using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
@@ -221,6 +222,11 @@ public static class ServiceCollectionExtensions
|
||||
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
|
||||
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
|
||||
// Node-local deployment-artifact cache. Registered by the Host's AddOtOpcUaLocalDb on
|
||||
// driver-role nodes only; deliberately left null elsewhere (admin-only graphs, test
|
||||
// harnesses) rather than given a null-object, so DriverHostActor skips caching outright
|
||||
// instead of pretending to cache into a sink that drops everything.
|
||||
var deploymentArtifactCache = resolver.GetService<IDeploymentArtifactCache>();
|
||||
// Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in
|
||||
// Host DI inside the hasDriver block; may be absent in some role configs / test harnesses,
|
||||
// in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host.
|
||||
@@ -338,7 +344,8 @@ public static class ServiceCollectionExtensions
|
||||
historyWriter: historyWriter,
|
||||
loggerFactory: loggerFactory,
|
||||
scriptRootLogger: scriptRootLogger,
|
||||
invokerFactory: invokerFactory),
|
||||
invokerFactory: invokerFactory,
|
||||
deploymentArtifactCache: deploymentArtifactCache),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
|
||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||
return;
|
||||
}
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc);
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
|
||||
foreach (var sub in subscribers)
|
||||
{
|
||||
sub.Tell(dep);
|
||||
|
||||
@@ -29,14 +29,30 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
{
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
|
||||
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
|
||||
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
|
||||
public sealed record DependencyValueChanged(
|
||||
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
|
||||
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, but an
|
||||
/// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the
|
||||
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
|
||||
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
|
||||
|
||||
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
||||
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
|
||||
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
|
||||
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
||||
/// every existing construction site source-compatible.</summary>
|
||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
/// <param name="IsReassert"><c>true</c> when this result is a deploy-time re-assertion of the last
|
||||
/// value/quality (see <see cref="ReassertValue"/>) rather than a fresh evaluation. The host still
|
||||
/// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a
|
||||
/// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to
|
||||
/// <c>false</c> so every existing construction site stays source-compatible.</param>
|
||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false);
|
||||
|
||||
private readonly string _virtualTagId;
|
||||
private readonly string _scriptId;
|
||||
@@ -105,6 +121,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_mux = mux;
|
||||
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<ReassertValue>(_ => OnReassertValue());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -122,6 +139,32 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||
}
|
||||
|
||||
/// <summary>Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a
|
||||
/// deploy that re-materialised our published UNS node to <c>BadWaitingForInitialData</c> recovers even when
|
||||
/// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the
|
||||
/// child has emitted at least once — the first dependency arrival publishes the initial value normally.
|
||||
/// <para>
|
||||
/// The result is flagged <see cref="EvaluationResult.IsReassert"/> so the host publishes it (repairing
|
||||
/// the reset node) but does NOT re-record it to the historian — it is a stale value stamped with a
|
||||
/// fresh deploy-time timestamp, not a real evaluation sample (M1).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ordering (why the re-publish lands on the freshly-materialised node, not the pre-reset one): the
|
||||
/// reset and this re-publish converge on the SAME single-threaded <c>OpcUaPublishActor</c>. On a deploy
|
||||
/// <c>DriverHostActor</c> enqueues <c>RebuildAddressSpace</c> (the reset) to that actor FIRST, then sends
|
||||
/// <c>ApplyVirtualTags</c> to the VirtualTag HOST; the host tells this child <c>ReassertValue</c>, and
|
||||
/// only then does this method Tell the host an <c>EvaluationResult</c> which the host bridges onward to
|
||||
/// the publish actor. That multi-hop chain guarantees the re-publish arrives AFTER <c>RebuildAddressSpace</c>
|
||||
/// in the publish actor's FIFO mailbox, so the materialise runs before the write.
|
||||
/// </para></summary>
|
||||
private void OnReassertValue()
|
||||
{
|
||||
if (!_hasLastValue && !_lastPublishedBad) return;
|
||||
var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
|
||||
Context.Parent.Tell(new EvaluationResult(
|
||||
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true));
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||
{
|
||||
_dependencies[msg.TagId] = msg.Value;
|
||||
|
||||
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
||||
///
|
||||
/// <para>
|
||||
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
|
||||
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
|
||||
/// materialises against — so the value always lands on a NodeId that exists.
|
||||
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
|
||||
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
|
||||
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
|
||||
/// always lands on a NodeId that exists.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VirtualTagHostActor : ReceiveActor
|
||||
@@ -149,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
// recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
|
||||
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
|
||||
// subscriptions and last-value dedup state.
|
||||
// Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
|
||||
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
|
||||
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var p in msg.Plans)
|
||||
{
|
||||
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
||||
@@ -165,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
mux: _mux));
|
||||
Context.Watch(child);
|
||||
_children[p.VirtualTagId] = child;
|
||||
newlySpawned.Add(p.VirtualTagId);
|
||||
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
|
||||
}
|
||||
|
||||
@@ -176,6 +181,24 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
_planByVtag[p.VirtualTagId] = p;
|
||||
}
|
||||
|
||||
// Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a
|
||||
// deploy, and on a deploy DriverHostActor enqueues RebuildAddressSpace — which re-materialises each
|
||||
// VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded OpcUaPublishActor FIRST,
|
||||
// then sends ApplyVirtualTags to THIS host. A surviving child keeps its value-dedup state, so an
|
||||
// unchanged recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with
|
||||
// a static dependency). Telling each survivor to ReassertValue makes it re-publish its last state; that
|
||||
// re-publish reaches the publish actor via a multi-hop chain (host → child ReassertValue → child →
|
||||
// parent EvaluationResult → OnResult → publish actor), so its AttributeValueUpdate lands AFTER
|
||||
// RebuildAddressSpace in the publish actor's FIFO mailbox — the materialise runs before the write, and
|
||||
// the node recovers. (The ordering does NOT come from ApplyVirtualTags itself reaching the publish
|
||||
// actor — it doesn't; it goes to this host.) Fresh children are skipped (nothing to re-assert; their
|
||||
// first dependency arrival publishes normally).
|
||||
foreach (var (vtagId, child) in _children)
|
||||
{
|
||||
if (newlySpawned.Contains(vtagId)) continue;
|
||||
child.Tell(VirtualTagActor.ReassertValue.Instance);
|
||||
}
|
||||
|
||||
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
|
||||
desired.Count, _children.Count);
|
||||
}
|
||||
@@ -192,15 +215,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
|
||||
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
|
||||
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
|
||||
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
|
||||
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||
nodeId, result.Value, result.Quality, result.TimestampUtc));
|
||||
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
|
||||
|
||||
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
|
||||
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
|
||||
// to. For a computed value source == server, so both timestamps are the evaluation time. Bad
|
||||
// results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot
|
||||
// status — so the historian trend can distinguish a degraded sample from a Good one.
|
||||
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
|
||||
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
|
||||
// node; recording it would append an artificial historian sample that never corresponded to a real
|
||||
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
|
||||
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
|
||||
// SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
|
||||
// timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
|
||||
// dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
|
||||
// sample from a Good one.
|
||||
if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||
{
|
||||
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
|
||||
_history.Record(nodeId, new DataValueSnapshot(
|
||||
@@ -223,9 +251,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared
|
||||
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also
|
||||
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
|
||||
/// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
|
||||
/// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
|
||||
/// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
|
||||
/// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
|
||||
/// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
|
||||
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
|
||||
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name);
|
||||
string.IsNullOrWhiteSpace(p.FolderPath)
|
||||
? V3NodeIds.Uns(p.EquipmentId, p.Name)
|
||||
: V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user