Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d148daf9 | |||
| 755ae1aa3f | |||
| 549c656489 | |||
| 28c2866710 | |||
| 154171f48c | |||
| 4dc2ad8084 | |||
| 1919a8e538 | |||
| b95b99ba8e | |||
| 4ad540376d | |||
| 57a5bb3c60 | |||
| b295b24419 | |||
| 585f827ef3 | |||
| be1df2d1e5 | |||
| 9b5a96876e | |||
| 898c7365c4 | |||
| 37f444b99c | |||
| 443b718158 | |||
| 02f2dafe2a | |||
| 10a6a37ca5 | |||
| a55b7e51ca | |||
| 14fe89c505 | |||
| 4c716242ac | |||
| 3cc8069150 | |||
| 48cbc26c34 | |||
| 9e7fa5d11f | |||
| e3e3f5fb04 | |||
| 861a1d1df0 | |||
| 9b30bdeb7a | |||
| 0efee7413a | |||
| 967e5140f1 | |||
| 50f88b938f | |||
| a05cff794c | |||
| 6de782e0fd | |||
| 3d7e1226d2 | |||
| 984cc875e8 | |||
| 6bb0108d30 | |||
| 531a110ffc | |||
| 53ff041229 | |||
| 5b7fe9336e | |||
| 3b75e1ac69 | |||
| 227cf8ee2b | |||
| b13ff0be46 | |||
| 963eec1b32 | |||
| 7a9caf141c | |||
| 88adec047b | |||
| 76e55fc112 | |||
| 4550486144 | |||
| 8dd9da7d4d | |||
| 9ed2251f37 | |||
| d1dac87f6f | |||
| 279d1d0fb1 | |||
| c50ebcf7dc | |||
| ecf5410433 | |||
| 7774797770 | |||
| 792f28ec7b | |||
| 3a4ed8ddb5 |
@@ -58,3 +58,6 @@ lib/
|
||||
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
|
||||
/pending.md
|
||||
/current.md
|
||||
|
||||
# Subagent-driven-development git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -226,13 +226,15 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
**A third bootstrap gap closed 2026-07-22 — downing was never the whole story, and the fix changed twice.** Even under `auto-down`, a node cold-starting while its peer was dead **never came Up**: Akka runs `FirstSeedNodeProcess` — the only bootstrap path that can form a *new* cluster when no peer answers `InitJoin` — exclusively when `seed-nodes[0]` is the node’s own address; every other node runs `JoinSeedNodeProcess` and retries `InitJoin` forever. **`Cluster:SeedNodes` is therefore ORDERED: every node that is one of its own seeds lists ITSELF first, partner second** (docker-dev `central-2` was swapped; site nodes list only `central-1` and are legitimately not seeds). `AkkaClusterOptionsValidator` (`AddValidatedOptions`/`ValidateOnStart` in `AddOtOpcUaCluster`) enforces it at boot — **conditionally** (self must be entry 0 only *if* self is in the list, or every site node would refuse to start), matching on `PublicHostname`+`Port`, never the `0.0.0.0` bind address. The earlier fix — a `Cluster:SelfFormAfter` timer calling `Cluster.Join(SelfAddress)` — is **deleted**: it sat outside Akka’s join handshake, so it could not tell "no seed answered" from "a seed answered and the join is in flight", and it islanded a manually-failed-over node live before a TCP reachability guard patched that one shape. Pinned by `SelfFirstSeedBootstrapTests` (real in-process clusters, incl. a falsifiability control proving peer-first ordering never forms) + `AkkaClusterOptionsValidatorTests`. See `docs/Redundancy.md` §"Bootstrap: self-first seed ordering".
|
||||
|
||||
**Simultaneous cold-start splits — the bootstrap guard closes it (opt-in, `Cluster:BootstrapGuard:Enabled`, default OFF).** Self-first on BOTH nodes means when both cold-start at the same instant, each runs `FirstSeedNodeProcess`, times out, and forms its OWN 1-node cluster → two Primaries in one pair (the Phase 6 live gate reproduced this on docker; two separate clusters do NOT auto-merge). The guard prevents it without losing cold-start-alone: the lexicographically **lower** `host:port` node is the preferred founder (self-first, forms immediately); the **higher** node TCP-probes the partner's Akka port up to `PartnerProbeSeconds` (25s) — **reachable ⇒ peer-first (join the founder)**, **unreachable ⇒ self-first (partner dead, form alone)**. The order is chosen BEFORE the single `JoinSeedNodes`, from an explicit reachability signal — it never re-forms mid-handshake (the `SelfFormAfter` failure mode). When on, Akka gets NO config seeds (`BuildClusterOptions` empties `ClusterOptions.SeedNodes`) and `ClusterBootstrapCoordinator` drives the join. Residual trade (accepted, operator-visible via a warning + restart-recovers): the higher node hangs if the founder dies in the probe→join window. docker-dev: **site-a = guard demo** (guard on, serialization removed); **site-b = A/B control** (guard off, `depends_on: service_healthy` serialization). The guard is the production-faithful fix (compose `depends_on` doesn't exist on real VMs). Pinned by `ClusterBootstrapGuardTests` + `ClusterBootstrapCoordinatorTests` (real 2-node clusters, incl. the higher-node-cold-start-alone case). See `docs/Redundancy.md` §"Bootstrap guard".
|
||||
|
||||
Assert the **effective** cluster config off a running `ActorSystem` (as `SplitBrainResolverActivationTests` does), never the typed options or the akka.conf text — several HOCON fragments compete and the losing one still reads correctly in the file.
|
||||
|
||||
**RESOLVED by per-cluster mesh Phase 6 (2026-07-24) — no longer per-Akka-cluster.** The "one Primary across the whole Akka mesh" limitation described above is gone; see the Phase 6 paragraph immediately below.
|
||||
|
||||
**Per-cluster mesh Phase 6 (2026-07-24) — the fleet is now three independent 2-node meshes, not one.** The single fleet-wide Akka mesh was split into one mesh per application `Cluster`: the central pair (`admin,driver,cluster-MAIN`), site-a (`driver,cluster-SITE-A`), site-b (`driver,cluster-SITE-B`) — same `ActorSystem` name, separated purely by per-pair self-first seeds (each node's `Cluster:SeedNodes` lists itself first, its own pair partner second, never a node from another cluster's pair). Consequences:
|
||||
|
||||
- **The redundancy singleton is cluster-scoped and spawned per driver node.** `RedundancyStateActor` is now a `cluster-{ClusterId}`-scoped singleton (falls back to plain `driver` for a legacy node), so each pair elects and publishes its **own** Primary on its own mesh's DPS. **Two or more Primaries exist fleet-wide, one per pair — by design, not a bug.**
|
||||
- **The redundancy singleton is cluster-scoped and spawned per driver node.** `RedundancyStateActor` is now a `cluster-{ClusterId}`-scoped singleton (falls back to plain `driver` for a legacy node), so each pair elects and publishes its **own** Primary on its own mesh's DPS. **Two or more Primaries exist fleet-wide, one per pair — by design, not a bug.** ⚠️ **Wiring landmine (fixed `3a4ed8dd`, caught by the live gate):** the singleton's cluster-role *scope* is computed at `AddAkka`-configurator time, so it must come from `AkkaClusterOptions` (config) — **never** by resolving `IClusterRoleInfo` from the SP there. `ClusterRoleInfo` depends on the `ActorSystem`, and that lambda runs *while the ActorSystem is being built*, so resolving it recurses into ActorSystem construction and **stack-overflows every node at boot** (compiles clean; the rehome tests passed a hand-built fake straight into the extension, mocking around the composition-root line). Keep `WithOtOpcUaClusterRedundancySingleton`/`BuildClusterRedundancySingletonOptions` taking `AkkaClusterOptions`.
|
||||
- `ClusterNodeAddressReconciler` is **own-cluster-scoped** — an admin node can only reconcile `ClusterNode` rows in its own cluster; it has no gossip visibility into another mesh.
|
||||
- `CentralCommunicationActor` creates **one `ClusterClient` per application `Cluster`** and fans commands across them (closing the `TODO(Phase 6)` Phase 2 left behind), rather than one fleet-wide client.
|
||||
- **`SplitTopologyTransportValidator` fails host start** on a node carrying a `cluster-{ClusterId}` role unless it also runs the split-safe transports: `MeshTransport:Mode=ClusterClient`, `Telemetry:Mode=Grpc` (driver), `TelemetryDial:Mode=Grpc` (admin). A DPS transport on a cluster-role node would try to gossip across a partition that no longer exists.
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
||||
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
@@ -140,7 +144,7 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<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" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.3.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
|
||||
@@ -148,8 +152,8 @@
|
||||
-->
|
||||
<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.Health.Akka" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
@@ -90,6 +93,9 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
|
||||
|
||||
@@ -100,9 +100,11 @@ The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts.
|
||||
|
||||
## Failover smoke
|
||||
|
||||
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `central-1` and `central-2` should be listed as healthy in the `otopcua-admin` service.
|
||||
2. `docker compose -f docker-dev/docker-compose.yml stop central-1` — `central-2` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `central-2` once its `/health/active` returns 200.
|
||||
3. `docker compose -f docker-dev/docker-compose.yml start central-1` — `central-1` rejoins as a follower; `central-2` keeps the leader role until something disturbs it.
|
||||
1. Watch the Traefik dashboard at `http://localhost:8089`. Exactly ONE of `central-1` / `central-2` is UP in the `otopcua-admin` service — the admin role-leader. The standby is DOWN by design: `/health/active` answers 503 on it, which is how Traefik pins browser traffic to the leader. (Before `ZB.MOM.WW.Health` 0.2.1 the standby answered 200, so both showed UP and this pinning silently never worked — see `lmxopcua#494`.)
|
||||
2. `docker compose -f docker-dev/docker-compose.yml stop central-1` — `central-2` picks up the admin role-leader within ~15 s (Akka split-brain stable-after) and Traefik flips it to UP. Verified 2026-07-24: the swap completed inside 20 s with **no gap** — `http://localhost:9200/` answered 200 continuously across the failover.
|
||||
3. `docker compose -f docker-dev/docker-compose.yml start central-1` — `central-1` rejoins and **reclaims** the admin role-leader, and Traefik flips back. It does not stay with `central-2`: Akka's `RoleLeader` is the lowest-address member, so the outcome is deterministic rather than sticky.
|
||||
|
||||
> **Cold-boot caveat.** Starting both central nodes simultaneously from stopped can leave each self-forming its own 1-member cluster (self-first seed lists), in which case BOTH are their own role-leader and both answer `/health/active` 200. Check `entries["akka"].data.memberCount` on `/health/ready` — it should read 2. Restarting either node makes it join the survivor.
|
||||
|
||||
## Resource limits & dev logging
|
||||
|
||||
|
||||
@@ -49,14 +49,19 @@
|
||||
# site-a-1, site-a-2 OTOPCUA_ROLES=driver,cluster-SITE-A — driver-only
|
||||
# site-b-1, site-b-2 OTOPCUA_ROLES=driver,cluster-SITE-B — members of their
|
||||
# OWN 2-node mesh (self-first seeded within the pair, NOT
|
||||
# off central). They serve no UI and authenticate no
|
||||
# users; central manages + deploys to them over the
|
||||
# off central). They serve no UI, but they DO expose OPC UA
|
||||
# data-plane endpoints (4842-4845) and authenticate those
|
||||
# users against the same shared GLAuth (via the *ldap-env
|
||||
# anchor); central manages + deploys to them over the
|
||||
# ClusterClient/gRPC/FetchAndCache transports above, not
|
||||
# gossip.
|
||||
#
|
||||
# Auth is real LDAP against the shared GLAuth on the Linux Docker host
|
||||
# (10.100.0.35:3893, dc=zb,dc=local) — there is no LDAP container here.
|
||||
# Only the admin-role central nodes carry the Security__Ldap__* block.
|
||||
# EVERY host node carries the Security__Ldap__* block (shared *ldap-env anchor):
|
||||
# the driver-only site nodes serve OPC UA endpoints too, and LDAP options are
|
||||
# validated at boot on all of them (a node with no LDAP config crashes with
|
||||
# OptionsValidationException — transport None + AllowInsecure false).
|
||||
# Sign in `multi-role` / `password`.
|
||||
#
|
||||
# traefik PathPrefix(`/`) → central-1 / central-2 (the single UI route).
|
||||
@@ -126,6 +131,43 @@ x-secrets-env: &secrets-env
|
||||
Secrets__Replication__AnnounceInterval: "00:00:05"
|
||||
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
|
||||
|
||||
# EVERY node that serves an OPC UA endpoint authenticates data-plane users against the shared GLAuth —
|
||||
# that includes the driver-only site nodes (ports 4842-4845), not just the admin/central pair. LDAP
|
||||
# options are validated at host start on ALL of them (Enabled=true by appsettings default, Transport=None,
|
||||
# AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot.
|
||||
# This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central
|
||||
# still also lists the block inline for readability; the explicit keys win over the merge, same values.
|
||||
x-ldap-env: &ldap-env
|
||||
Security__Ldap__Enabled: "true"
|
||||
Security__Ldap__DevStubMode: "false"
|
||||
Security__Ldap__Server: "10.100.0.35"
|
||||
Security__Ldap__Port: "3893"
|
||||
Security__Ldap__Transport: "None"
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
|
||||
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
|
||||
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
|
||||
Security__Ldap__GroupToRole__WriteConfigure: "WriteConfigure"
|
||||
Security__Ldap__GroupToRole__AlarmAck: "AlarmAck"
|
||||
|
||||
# Readiness gate for a pair FOUNDER node (site-a-1 / site-b-1): passes once its Akka port 4053 is bound.
|
||||
# The partner (site-a-2 / site-b-2) waits on this via `depends_on: { condition: service_healthy }` so the
|
||||
# founder forms its 2-node mesh FIRST and the partner JOINS it — instead of both self-first seeds racing
|
||||
# and each forming its own single-node cluster (the split brain the Phase 6 live gate observed: 250/250,
|
||||
# both Primary). `service_started` is NOT enough — it fires the instant the container launches, long
|
||||
# before Akka binds. The image ships bash but no nc/curl, so the probe uses bash's /dev/tcp. start_period
|
||||
# gives the founder a head start to not just bind but FORM (a 1-node cluster forms within the same second
|
||||
# as the bind), so the partner's InitJoin lands on an existing cluster and is ack'd.
|
||||
x-akka-founder-healthcheck: &akka-founder-healthcheck
|
||||
test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/4053' || exit 1"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
start_period: 8s
|
||||
|
||||
services:
|
||||
|
||||
sql:
|
||||
@@ -226,10 +268,16 @@ services:
|
||||
sql: { condition: service_healthy }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-env]
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-1"
|
||||
@@ -345,10 +393,16 @@ services:
|
||||
central-1: { condition: service_started }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-env]
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-2"
|
||||
@@ -438,6 +492,7 @@ services:
|
||||
|
||||
site-a-1:
|
||||
<<: *otopcua-host
|
||||
healthcheck: *akka-founder-healthcheck
|
||||
depends_on:
|
||||
sql: { condition: service_healthy }
|
||||
central-1: { condition: service_started }
|
||||
@@ -473,6 +528,12 @@ services:
|
||||
# FetchAndCache, never gossip; central and site-a share no seed list.
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
|
||||
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-2:4053"
|
||||
# Simultaneous-cold-start split-brain guard (SITE-A enablement demo). With the pair no longer
|
||||
# startup-serialized, this is what stops both self-first seeds forming their own 1-node cluster:
|
||||
# the lower-address node (site-a-1) founds immediately; the higher (site-a-2) probes site-a-1 and
|
||||
# joins it when reachable, or forms alone only if site-a-1 is truly down. Akka gets NO config
|
||||
# seeds when this is on (BuildClusterOptions); ClusterBootstrapCoordinator issues JoinSeedNodes.
|
||||
Cluster__BootstrapGuard__Enabled: "true"
|
||||
Cluster__Roles__0: "driver"
|
||||
Cluster__Roles__1: "cluster-SITE-A"
|
||||
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
|
||||
@@ -487,7 +548,7 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-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"
|
||||
@@ -545,6 +606,11 @@ services:
|
||||
sql: { condition: service_healthy }
|
||||
central-1: { condition: service_started }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
# SITE-A is the BOOTSTRAP-GUARD enablement demo: NO startup serialization on the pair — both
|
||||
# nodes start simultaneously (the exact race that split-brained in the Phase 6 gate). The
|
||||
# Cluster:BootstrapGuard (enabled in both site-a env blocks) is the ONLY thing preventing the
|
||||
# split: it makes the lower-address node the founder and the higher-address node probe-then-join.
|
||||
# site-b keeps the depends_on:service_healthy serialization + guard OFF, as the A/B control.
|
||||
environment:
|
||||
OTOPCUA_ROLES: "driver,cluster-SITE-A"
|
||||
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
|
||||
@@ -567,6 +633,9 @@ services:
|
||||
# second) — it no longer seeds off central-1.
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-2:4053"
|
||||
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-1:4053"
|
||||
# Bootstrap-guard enablement demo — see site-a-1. site-a-2 is the HIGHER address, so the guard
|
||||
# makes it probe site-a-1 and join it (peer-first) rather than race-form its own cluster.
|
||||
Cluster__BootstrapGuard__Enabled: "true"
|
||||
Cluster__Roles__0: "driver"
|
||||
Cluster__Roles__1: "cluster-SITE-A"
|
||||
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
|
||||
@@ -580,7 +649,7 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-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}"
|
||||
@@ -628,6 +697,7 @@ services:
|
||||
|
||||
site-b-1:
|
||||
<<: *otopcua-host
|
||||
healthcheck: *akka-founder-healthcheck
|
||||
depends_on:
|
||||
sql: { condition: service_healthy }
|
||||
central-1: { condition: service_started }
|
||||
@@ -667,7 +737,7 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-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}"
|
||||
@@ -700,6 +770,10 @@ services:
|
||||
sql: { condition: service_healthy }
|
||||
central-1: { condition: service_started }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
# Serialize the SITE-B pair's startup so site-b-1 forms the mesh first and site-b-2 JOINS it — see
|
||||
# the identical comment on site-a-2. Without this both self-first seeds form their own single-node
|
||||
# cluster on simultaneous start (split brain).
|
||||
site-b-1: { condition: service_healthy }
|
||||
environment:
|
||||
OTOPCUA_ROLES: "driver,cluster-SITE-B"
|
||||
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
|
||||
@@ -735,7 +809,7 @@ services:
|
||||
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
|
||||
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
|
||||
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
|
||||
<<: *secrets-env
|
||||
<<: [*secrets-env, *ldap-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}"
|
||||
|
||||
+55
-5
@@ -401,11 +401,61 @@ Comparing against `Cluster:Hostname` would be worse than wrong: in docker-dev it
|
||||
no seed URI anywhere, so the rule would silently exempt the entire rig.
|
||||
|
||||
Sequential recovery is island-free by construction: a `FirstSeedNodeProcess` node self-joins only when **no**
|
||||
seed answered, so a peer booting after the survivor formed simply joins it as the youngest member. Both
|
||||
nodes cold-starting *simultaneously* converge on one cluster while they are mutually reachable — the
|
||||
`InitJoin` handshake resolves it before either self-join deadline expires. The residual risk is a boot-time
|
||||
**partition** (both cold-starting, mutually unreachable): both form, which is the same dual-active class the
|
||||
`auto-down` strategy already accepts, with the same recovery (restart one side).
|
||||
seed answered, so a peer booting after the survivor formed simply joins it as the youngest member.
|
||||
|
||||
**Simultaneous cold-start CAN split — this is the residual gap the bootstrap guard below closes.** When
|
||||
BOTH nodes start from nothing at the same instant, each runs `FirstSeedNodeProcess`, and if neither has
|
||||
formed before the other's `seed-node-timeout` expires, EACH self-joins and forms its own single-node
|
||||
cluster — two Primaries in one pair. On in-process loopback the `InitJoin` handshake usually resolves fast
|
||||
enough to converge, but the Phase 6 live gate reproduced the split reliably on the docker-dev rig (real
|
||||
container start timing + DNS): both nodes logged `JOINING itself … forming a new cluster` and both advertised
|
||||
ServiceLevel 250. Two independent clusters do **not** auto-merge, so the split persists until an operator
|
||||
restarts one side.
|
||||
|
||||
### Bootstrap guard (`Cluster:BootstrapGuard`, opt-in)
|
||||
|
||||
The guard eliminates the simultaneous-start split without giving up cold-start-alone. It is a **dark switch,
|
||||
default off** — a node with `Cluster:BootstrapGuard:Enabled=false` (the default) keeps Akka's config-driven
|
||||
self-first auto-join exactly as above.
|
||||
|
||||
When enabled, the node is given **no config seed nodes** (so Akka does not auto-join), and
|
||||
`ClusterBootstrapCoordinator` picks the join order from a deterministic address tie-break plus a reachability
|
||||
probe:
|
||||
|
||||
- The node with the lexicographically **lower** canonical `host:port` is the **preferred founder**: it joins
|
||||
self-first and forms immediately if no peer answers — no probe, no delay.
|
||||
- The **higher** node probes its partner's Akka port (a plain TCP connect; the partner binds its port well
|
||||
before it forms) for up to `PartnerProbeSeconds` (default 25 s): **reachable ⇒ peer-first** (it joins the
|
||||
founder, never races it); **unreachable after the window ⇒ self-first** (the partner is genuinely down, so
|
||||
it forms alone — cold-start-alone preserved for the higher node too).
|
||||
|
||||
The order is decided **before** issuing a single `Cluster.JoinSeedNodes`, from an explicit reachability
|
||||
signal — it never re-decides mid-handshake, the failure mode that retired the `SelfFormAfter` watchdog.
|
||||
|
||||
**Residual trade-off (accepted, operator-visible):** once the higher node commits peer-first it cannot
|
||||
self-form (Akka's `JoinSeedNodeProcess` retries forever). If the founder dies in the small probe→join window,
|
||||
the higher node hangs unjoined; the coordinator logs a clear WARNING after a bounded grace, and a **restart
|
||||
recovers it** (the guard re-runs, finds the founder down, and forms alone). Config keys:
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `Cluster:BootstrapGuard:Enabled` | `false` | Dark switch. Only meaningful on a node that is one of its own two pair seeds; inert elsewhere. |
|
||||
| `Cluster:BootstrapGuard:PartnerProbeSeconds` | `25` | Higher node's probe window. Must comfortably exceed the founder's process-start-to-Akka-bind time, or a slow-but-alive founder is mistaken for dead and the split re-opens. Validated `> 0` at boot. |
|
||||
| `Cluster:BootstrapGuard:PartnerProbeIntervalMs` | `500` | Interval between probes. |
|
||||
| `Cluster:BootstrapGuard:ProbeConnectTimeoutMs` | `1000` | Per-probe TCP connect timeout. |
|
||||
|
||||
On the docker-dev rig the **site-a pair is the enablement demo** (guard on, startup serialization removed so
|
||||
both nodes cold-start simultaneously); **site-b keeps the `depends_on: service_healthy` startup serialization
|
||||
with the guard off**, as the A/B control. Either mechanism prevents the split; the guard is the one that also
|
||||
works on production hardware, where compose `depends_on` does not exist.
|
||||
|
||||
The alternative to the guard is **operational**: stagger the two VMs' service-manager start, or bring the
|
||||
designated founder VM up first — see the Phase 7 co-located operator runbook
|
||||
(`docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
|
||||
|
||||
The residual risk that remains even with the guard is a boot-time **partition** (both cold-starting, mutually
|
||||
unreachable from the start): both form, which is the same dual-active class the `auto-down` strategy already
|
||||
accepts, with the same recovery (restart one side).
|
||||
|
||||
Pinned by `SelfFirstSeedBootstrapTests` (`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/`), which starts real
|
||||
in-process clusters through the production bootstrap at production failure-detection timings: a lone
|
||||
|
||||
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
|
||||
|
||||
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
|
||||
|
||||
### Post-(re)load condition-node re-assert (#487)
|
||||
|
||||
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
|
||||
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
|
||||
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
|
||||
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
|
||||
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
|
||||
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
|
||||
never come.
|
||||
|
||||
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
|
||||
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
|
||||
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
|
||||
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
|
||||
|
||||
Four properties are load-bearing:
|
||||
|
||||
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
|
||||
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
|
||||
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
|
||||
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
|
||||
machinery — or a future refactor — to mistake for a transition.
|
||||
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
|
||||
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
|
||||
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
|
||||
alarm's display name — and writes nothing at all on the common all-normal deploy.
|
||||
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
|
||||
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
|
||||
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
|
||||
these writes.
|
||||
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
|
||||
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
|
||||
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
|
||||
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
|
||||
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
|
||||
|
||||
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
|
||||
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
|
||||
|
||||
## Source integration
|
||||
|
||||
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
|
||||
|
||||
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||||
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
|
||||
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
|
||||
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
|
||||
- `ServerTimestampUtc`: `DateTime.UtcNow`.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
|
||||
**Step 4: run, expect PASS.**
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ OpcUaClient, Historian.Gateway (`src/Drivers/`).
|
||||
|
||||
## 2. Document map
|
||||
|
||||
**Program:** this file.
|
||||
**Program:** this file (architecture / shared contract / build order).
|
||||
**Progress tracker (Waves 0–2):** [`2026-07-24-driver-expansion-tracking.md`](2026-07-24-driver-expansion-tracking.md)
|
||||
— per-deliverable status + links to each design doc and implementation plan.
|
||||
|
||||
**Research reports** (`docs/research/drivers/`, index: [`README.md`](../research/drivers/README.md)):
|
||||
- [`mtconnect-agent.md`](../research/drivers/mtconnect-agent.md) ·
|
||||
|
||||
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` (state word; §3.6) |
|
||||
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
|
||||
### 3.4 `IReadable` — `/current`
|
||||
|
||||
|
||||
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
|
||||
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
|
||||
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
|
||||
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
|
||||
|
||||
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
|
||||
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
|
||||
and the decisions worth knowing before touching it:
|
||||
|
||||
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
|
||||
to the string the catalog returned, so the text quoted into a poll query is one this driver read
|
||||
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
|
||||
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
|
||||
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
|
||||
match under a case-sensitive collation is refused rather than guessed, because picking one would
|
||||
publish another column's data under the operator's node.
|
||||
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
|
||||
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
|
||||
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
|
||||
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
|
||||
hunting a typo has to see what they wrote.
|
||||
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
|
||||
but stays in the *authored* table, so it still materializes as a node and reads
|
||||
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
|
||||
drop is logged at Warning with the tag, the field and the reason.
|
||||
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
|
||||
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
|
||||
them would serve a confidently-empty address space and send the operator hunting typos that do not
|
||||
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
|
||||
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
|
||||
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
|
||||
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
|
||||
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
|
||||
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
|
||||
wall-clock bound as the liveness check (R2-01 / STAB-14).
|
||||
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
|
||||
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
|
||||
message pointing at the fix — expose the data through a view in the connected database.
|
||||
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
|
||||
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
|
||||
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
|
||||
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
|
||||
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
|
||||
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
|
||||
this keeps the factory shape unchanged.
|
||||
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it
|
||||
in display/logging and flags it dev-only.
|
||||
- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
|
||||
the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
|
||||
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
|
||||
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
|
||||
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
|
||||
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
|
||||
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
|
||||
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
|
||||
sees them). The key is matched case-insensitively, and the error text never echoes the value.
|
||||
- **Never log the resolved connection string.** Log only provider + server host + database name.
|
||||
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
|
||||
Kerberos) so no password transits config at all.
|
||||
|
||||
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
|
||||
|
||||
## Documented follow-ups (non-blocking)
|
||||
|
||||
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
|
||||
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
|
||||
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
|
||||
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
|
||||
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
|
||||
(a projection also carries the definition's severity, the resolved message template, and the last-observed
|
||||
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
|
||||
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
|
||||
nodes at all. The original deferral text follows for context.
|
||||
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
|
||||
|
||||
@@ -339,4 +339,4 @@ resource sizing on the site VMs should be checked once both products run the ful
|
||||
| 4 cut driver ConfigDb | **DONE 2026-07-23 — live gate PASSED** on `feat/mesh-phase4` (Tasks 0–8 + 1b + 10–11; Task 9 table-drop deferred). ConfigDb admin-only; driver-only ⇒ FetchAndCache (validator); `DbHealthProbeActor` not spawned driver-only (client-visible ServiceLevel 240/250 with central SQL down — proven live); alarm condition state in replicated LocalDb `alarm_condition_state`; central persists acks; `OpcUaPublish` guard split fixes the driver-only address-space wipe; driver-only LDAP maps from appsettings only (6th consumer found mid-phase). Gate: deploy sealed green w/ 4 DB-less site nodes acking, ServiceLevel held 240 w/ SQL down, restart booted last-known-good from the LocalDb pointer. See `2026-07-23-mesh-phase4-cut-driver-configdb.md` + `2026-07-23-mesh-phase4-live-gate.md`. |
|
||||
| 5 gRPC telemetry | **DONE 2026-07-23 — live gate PASSED** on `feat/mesh-phase5`. 4-channel scope (`alerts`/`script-logs`/`driver-health`/`driver-resilience-status`), 3 deferred with rationale (`redundancy-state`, `fleet-status`, `deployment-acks`); dark switch `Telemetry:Mode`/`TelemetryDial:Mode` (Dps default); node hosts server / central dials; auth-from-day-one fail-closed bearer key, superseding design §6.3. Gate: full 12-stream mesh formed in Grpc mode (2 inbound per driver node, 6 outbound per central), AdminUI pill live (data path proven), kill-and-reconnect of a node recovered its stream in ~5s; Dps baseline dials nothing. Surfaced an upgrade gotcha — `ClusterNode.GrpcPort` must be populated on existing deployments (fresh installs seed it; nullable column doesn't backfill) — which live-validated the graceful null-`GrpcPort` skip. See `docs/Telemetry.md`, `2026-07-23-mesh-phase5-grpc-telemetry-stream.md` + `2026-07-23-mesh-phase5-live-gate.md`. |
|
||||
| 6 mesh partition + co-location | **DONE 2026-07-24** — three independent 2-node meshes (central/site-a/site-b), `cluster-{ClusterId}` roles, per-pair self-first seeds, cluster-scoped `RedundancyStateActor` singleton (two+ Primaries fleet-wide, by design), one `ClusterClient` per `Cluster`, `SplitTopologyTransportValidator`, pair-local secrets replication, compiled transport defaults intentionally kept `Dps`. See `2026-07-24-mesh-phase6-partition-and-colocation.md`. |
|
||||
| 7 drill + live gates | not started |
|
||||
| 7 drill + live gates | **DONE 2026-07-24 — all 6 drills PASSED** on the docker-dev three-mesh rig. D1 MAIN graceful manual-failover via the AdminUI button (Primary leaves, peer→250, restarts+rejoins no split); D2 SITE-A auto-down failover both directions; D3 SITE-B crash-the-oldest; D4 **auto-down 1-vs-1 survival (closes the gate deferred since Phase 0a)** — every survivor stayed Up; D5 **self-first cold-start-alone (gate b)** — a node boots alone and forms its mesh; D6 recovery/rejoin. Manual-failover button confirmed MAIN-only by construction (site pairs are driver-only, fail over via auto-down). One-page co-located operator runbook shipped. Carried Phase-6 finding: simultaneous cold-start of both pair VMs can split — operational mitigation (staggered start) in the runbook; product-level guard is a candidate follow-up, not shipped. See `2026-07-24-mesh-phase7-failover-drills.md`. **Per-cluster mesh program COMPLETE.** |
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Driver-expansion — Waves 0–2 tracking
|
||||
|
||||
> **Living status tracker** for the driver-expansion program (Waves 0, 1, 2). One row per
|
||||
> deliverable, each pointing at its **design doc**, its **implementation plan** (once written),
|
||||
> and its current **status**. The authoritative *architecture* index is the program design doc;
|
||||
> this file is the authoritative *progress* index.
|
||||
>
|
||||
> **Program design (architecture / shared contract / build order):**
|
||||
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
|
||||
>
|
||||
> Last updated: 2026-07-24.
|
||||
|
||||
## Legend
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| ✅ **Done** | Merged to master, tests green. |
|
||||
| 🟡 **Live gate open** | Code merged; a live `/run` or hardware-gated verification still outstanding. |
|
||||
| 📝 **Plan ready** | Executable implementation plan (`*-implementation.md` + `.tasks.json`) written; not yet built. |
|
||||
| 📐 **Design only** | Design doc exists; **no** implementation plan yet — writing-plans is the next step. |
|
||||
| ⛔ **Not started** | No design, no plan. |
|
||||
|
||||
**Doc types** (per the writing-plans skill): a *design* states architecture, decisions, and risks;
|
||||
an *implementation plan* is the bite-sized, TDD, file-path-level task list the subagent-driven
|
||||
executor runs off, with a co-located `.tasks.json` for resume. A deliverable is only buildable once
|
||||
it reaches 📝.
|
||||
|
||||
## Summary
|
||||
|
||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | S–M | Dockerized `mtconnect/cppagent` — CI-simulatable |
|
||||
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | M–L | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
|
||||
|
||||
> Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8,
|
||||
> not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is
|
||||
> env-gated live. Both are out of this file's scope until they're pulled forward.
|
||||
|
||||
---
|
||||
|
||||
## Executing a plan (git worktree + subagent-per-task)
|
||||
|
||||
Each 📝 plan is executed with the **subagent-driven-development** skill: it sets up an isolated
|
||||
**git worktree** first (via `using-git-worktrees`), then dispatches a **fresh subagent per task**,
|
||||
running the classification-driven review chain (`trivial` = implement only … `high-risk` =
|
||||
spec-review → code-review → integration review) between tasks. The `.tasks.json` next to each plan
|
||||
tracks progress and lets a later session resume.
|
||||
|
||||
**Paste one of these into Claude Code to build a driver:**
|
||||
|
||||
| Deliverable | Command |
|
||||
|---|---|
|
||||
| Modbus RTU (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree` |
|
||||
| SQL poll (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree` |
|
||||
| MTConnect (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree` |
|
||||
| MQTT/Sparkplug (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree` |
|
||||
|
||||
- **Slash-command form** (equivalent): `/superpowers-extended-cc:subagent-driven-development <plan-path>`.
|
||||
- **Parallel-session / resume form** (batch execution with checkpoints, no fresh-subagent-per-task):
|
||||
`/superpowers-extended-cc:executing-plans <plan-path>` — reads the same `.tasks.json` and continues
|
||||
from the first pending task.
|
||||
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
|
||||
see each wave below for the gating notes). Run one plan per worktree; the four plans are
|
||||
independent, so separate worktrees may run concurrently.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — Universal Discover-backed browser 🟡
|
||||
|
||||
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
|
||||
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
|
||||
`ITagDiscovery.SupportsOnlineDiscovery` gate) that turns any driver's `ITagDiscovery.DiscoverAsync`
|
||||
into an AdminUI browse tree. It is the **Wave-0 gate**: every browsable new driver depends on this
|
||||
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
|
||||
marginal cost.
|
||||
|
||||
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
|
||||
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
|
||||
- **Status: 🟡 code-complete + merged, live gate open.**
|
||||
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
|
||||
- Implementation plan: **19 / 19 tasks completed.**
|
||||
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
|
||||
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — low-effort / high-leverage pair 📝
|
||||
|
||||
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
|
||||
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
|
||||
|
||||
### Modbus RTU — 📝 Plan ready
|
||||
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
|
||||
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
|
||||
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
|
||||
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
|
||||
gateway is the only shipped mode, so there is no serial hardware gate.
|
||||
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
|
||||
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
|
||||
|
||||
### SQL poll — 📝 Plan ready
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks**. `ISqlDialect` seam in from day one; only the SQL Server dialect built in v1 (Postgres/ODBC deferred).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server P1;
|
||||
Postgres/ODBC land in P2/P3 behind `ISqlDialect`).
|
||||
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
|
||||
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
|
||||
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
|
||||
central SQL Server (it hosts `ConfigDb`).
|
||||
- **Effort:** S–M.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — strategic telemetry / UNS pair 📝
|
||||
|
||||
Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
### MTConnect Agent — 📝 Plan ready
|
||||
- **Design:** [`2026-07-15-mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks**. Task 0 is the TrakHound-vs-hand-rolled client decision; browse-picker live-verify is gated on Wave-0 #468.
|
||||
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
|
||||
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
|
||||
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
|
||||
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
|
||||
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
|
||||
- **Effort:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 wk hand-rolled).
|
||||
|
||||
### MQTT / Sparkplug B — 📝 Plan ready
|
||||
- **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks** (P1 plain MQTT = Tasks 0–14, a complete shippable milestone; P2 Sparkplug B = Tasks 15–26). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
|
||||
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
|
||||
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
|
||||
(vendored Tahu proto, birth/alias/seq-gap/rebirth state machine, death→STALE).
|
||||
- **Fixture:** Mosquitto/EMQX broker (TLS + real auth, not anonymous) + a **project-owned C#
|
||||
Sparkplug edge-node simulator** for the rebirth/seq-gap/death test matrix + env-gated live suite
|
||||
(`MQTT_FIXTURE_ENDPOINT`).
|
||||
- **Effort:** M–L. **Top risks:** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's
|
||||
fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client).
|
||||
|
||||
---
|
||||
|
||||
## Next actions
|
||||
|
||||
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
|
||||
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
|
||||
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
|
||||
table (subagent-driven-development in a git worktree).
|
||||
3. **Build Wave 2** (MTConnect, then MQTT/Sparkplug) — both plans 📝 ready. MTConnect's browse leg
|
||||
wants #468 closed first; MQTT's Task 0 pinning spike gates everything after it.
|
||||
|
||||
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Per-cluster mesh Phase 6 — live gate record
|
||||
|
||||
> **Exit gate for Phase 6** (mesh partition). Bring up the rewritten docker-dev rig
|
||||
> (three independent 2-node Akka meshes) against **merged master** and prove every
|
||||
> previously-live-gated behaviour *per pair*, plus the split itself.
|
||||
>
|
||||
> Merge landed first (`origin/master` @ `2839deb5`, "merge now, gate after"); this gate
|
||||
> runs against the merged code and **fixes forward on master** if anything surfaces.
|
||||
|
||||
**Rig topology** (docker-dev, local OrbStack):
|
||||
|
||||
| Mesh | Nodes | Roles | OPC UA (host) | Seeds (self-first, pair-local) |
|
||||
|---|---|---|---|---|
|
||||
| MAIN | central-1, central-2 | `admin,driver,cluster-MAIN` | 4840 / 4841 | own pair only |
|
||||
| SITE-A | site-a-1, site-a-2 | `driver,cluster-SITE-A` | 4842 / 4843 | own pair only |
|
||||
| SITE-B | site-b-1, site-b-2 | `driver,cluster-SITE-B` | 4844 / 4845 | own pair only |
|
||||
|
||||
- Akka port 4053 (per container); telemetry gRPC `:4056`; ConfigServe `:4055` (central);
|
||||
LocalDb sync `:9001` (site-a); AdminUI `http://localhost:9200` (Traefik → central-1/2).
|
||||
- Transports: `MeshTransport__Mode=ClusterClient` (all), `Telemetry__Mode=Grpc` (all six),
|
||||
`TelemetryDial__Mode=Grpc` (central pair). Secrets replication pair-local.
|
||||
- Deploy: `POST http://localhost:9200/api/deployments`, header `X-Api-Key: docker-dev-deploy-key`.
|
||||
|
||||
---
|
||||
|
||||
## Legs
|
||||
|
||||
| # | Leg | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Three meshes form — each node sees ONLY its own pair (2 members) | ✅ PASS | each node handshakes ONLY its pair partner |
|
||||
| 2 | Two+ Primaries, one per pair; ServiceLevel 250/240 within each pair | ✅ PASS | MAIN central-2=250/central-1=240; SITE-A site-a-1=250/site-a-2=240; SITE-B site-b-1=250/site-b-2=240 |
|
||||
| 3 | Deploy reaches all clusters over per-cluster ClusterClient; seals green | ✅ PASS | "Deployment 019dbd99… sealed (acks=6)"; all 6 NodeDeploymentState rows acked; one ClusterClient per site cluster |
|
||||
| 4 | Telemetry per cluster over gRPC (:4056); pills live; kill/reconnect ~5s | ✅ PASS | central-1 6 outbound :4056 dials, each site node 2 inbound (both admin dial in), 0 failures/30s; site-b restart failed→recovered the streams |
|
||||
| 5 | Reconciler quiet — no `EnabledRowNotInCluster` for foreign rows | ✅ PASS | central-2 reconciler: "reconcile with cluster membership (2 driver members)" — own MAIN pair only; 0 foreign-row errors |
|
||||
| 6 | Secrets pair-local — no cross-mesh replication / no unreachable-peer errors | ✅ PASS (by fallback criterion) | 0 secret errors fleet-wide; DPS topic rides each pair's isolated mesh (Leg 1) ⇒ structurally pair-local |
|
||||
| 7 | Split validator — a site node flipped to `MeshTransport__Mode=Dps` refuses to boot | ✅ PASS | "Cluster:Roles declares a cluster-SITE-B role … MeshTransport:Mode is 'Dps' … set MeshTransport:Mode=ClusterClient" |
|
||||
| 8 | Failover within a pair — kill a Primary, Secondary takes over (auto-down 1-vs-1) | ✅ PASS | killed site-a-1 (Primary); site-a-2 auto-downed it, took the singleton, 240→250, survived alone (auto-down 1-vs-1 = deferred Phase 0a gate, now proven) |
|
||||
|
||||
**Phase 6 exit gate: MET.** All 8 legs pass. Three findings fixed forward on master (`3a4ed8dd` product
|
||||
boot-crash, `792f28ec` rig LDAP + split-brain serialization). One test-methodology note: the Leg 7
|
||||
`compose run … site-b-2` throwaway shares the `site-b-2` network alias and briefly disrupted the SITE-B
|
||||
Akka association — restart the affected pair after that probe (done), or run the validator check via a
|
||||
container that does not join the compose network alias.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### FINDING 1 (fix-forward on master) — boot-crash: `StackOverflowException` on EVERY node
|
||||
|
||||
**Symptom.** First `up` of the fresh Phase-6 images: all six nodes crash at startup with
|
||||
`Stack overflow.` — an infinite recursion entering through
|
||||
`ZB.MOM.WW.OtOpcUa.Cluster.ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap`.
|
||||
|
||||
**Root cause (Phase 6, Task 2 wiring).** `Program.cs` resolved `IClusterRoleInfo` **eagerly, inside
|
||||
the `AddAkka` configurator lambda**:
|
||||
|
||||
```csharp
|
||||
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>()); // line 385
|
||||
```
|
||||
|
||||
That lambda runs *while the `ActorSystem` is being constructed*, but `ClusterRoleInfo`'s ctor
|
||||
**depends on the `ActorSystem`** (it is a live `Cluster.State` view). Resolving it there forces a
|
||||
nested `ActorSystem` build → re-runs the same configurator → `sp.GetRequiredService<IClusterRoleInfo>()`
|
||||
again → ∞. The cycle:
|
||||
`WithOtOpcUaClusterBootstrap → GetService<IClusterRoleInfo> → ActorSystemFactory → WithOtOpcUaClusterBootstrap → …`
|
||||
|
||||
**Why merge + build + tests + reviews all missed it.** It compiles cleanly (a *runtime* DI cycle,
|
||||
not a compile error). `RedundancyStateSingletonRehomeTests` boots a real host but passes a hand-built
|
||||
`FakeClusterRoleInfo` **directly** into the extension — it never exercised the `sp.GetRequiredService`
|
||||
line in the composition root that overflows. The bug lived in `Program.cs`, which every test mocked
|
||||
around. Only a real boot of the shipped image surfaces it — precisely the live gate's job.
|
||||
|
||||
**Fix.** Derive the singleton's cluster-role scope from `AkkaClusterOptions` (pure config, no
|
||||
ActorSystem) instead of `IClusterRoleInfo`:
|
||||
- `BuildClusterRedundancySingletonOptions(AkkaClusterOptions)` + `WithOtOpcUaClusterRedundancySingleton(AkkaClusterOptions)`
|
||||
— derive `Role = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole) ?? RoleParser.Driver`
|
||||
(identical to `ClusterRoleInfo.ClusterRole`, which derives from the same config).
|
||||
- `Program.cs` passes `sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value` — `IOptions<>` has
|
||||
no ActorSystem dependency, breaking the cycle.
|
||||
- Tests updated to pass `AkkaClusterOptions { Roles = … }`. 5/5 rehome tests pass; Host builds clean.
|
||||
|
||||
Files: `Program.cs`, `ControlPlane/ServiceCollectionExtensions.cs`, `RedundancyStateSingletonRehomeTests.cs`.
|
||||
Status: **FIXED + live-verified** — rebuilt image boots with zero stack-overflow lines; all six nodes
|
||||
start; cluster singletons form (`redundancy-state` identified per mesh). 5/5 rehome tests pass.
|
||||
|
||||
### FINDING 2 (fix-forward on master) — driver-only site nodes crash: LDAP options fail validation
|
||||
|
||||
**Symptom.** site-a-1/site-a-2/site-b-1/site-b-2 crash at `Host.StartAsync` with
|
||||
`OptionsValidationException: LDAP transport is None (plaintext) but AllowInsecure is false`.
|
||||
|
||||
**Root cause (Task 7 rig).** Only central-1/central-2 carried the `Security__Ldap__*` env block; the
|
||||
compose even asserted "Only the admin-role central nodes carry" it. But the site nodes serve OPC UA
|
||||
endpoints (4842-4845) and LDAP options are validated at boot on every node (appsettings default
|
||||
`Enabled=true`, `Transport=None`, `AllowInsecure=false` ⇒ throw). The site nodes overrode `environment`
|
||||
wholesale (YAML `<<:` does not deep-merge `environment`), so they inherited no LDAP config. `docker
|
||||
compose config` validates the file but never boots the app, so this passed the Task 7 acceptance check.
|
||||
|
||||
**Fix (rig).** Extracted the LDAP block to a shared `&ldap-env` anchor merged into **every** host node
|
||||
(`<<: [*secrets-env, *ldap-env]`); corrected the two misleading header comments. Site nodes now boot.
|
||||
|
||||
### FINDING 3 (fix-forward on master) — site pairs split-brain at simultaneous startup (250/250)
|
||||
|
||||
**Symptom.** Both nodes of each site pair logged `is JOINING itself ... and forming a new cluster` and
|
||||
each elected itself Primary → ServiceLevel **250/250** instead of 250/240. Each pair was two independent
|
||||
1-node clusters, not one 2-node mesh.
|
||||
|
||||
**Root cause (Task 7 rig — a startup race, not a code defect).** Both pair nodes are self-first seeds
|
||||
(`seed[0]=self`), so both run Akka's `FirstSeedNodeProcess` and can form alone. The partner
|
||||
(site-a-2/site-b-2) only `depends_on` sql/central/migrator — **not its pair founder** — so the pair
|
||||
started simultaneously and each formed its own cluster before discovering the other. The central pair
|
||||
avoids this only because central-2 `depends_on central-1` (and even then won the InitJoin race by luck).
|
||||
`depends_on: service_started` is insufficient — it fires when the container launches, before Akka binds.
|
||||
This is an inherent property of symmetric self-first seeding (present for central since #459), now
|
||||
extended to sites; **carry to Phase 7** as a production concern (simultaneous cold-start of both pair
|
||||
VMs). NOTE: two separate 1-node clusters do NOT merge via SBR — auto-down resolves partitions of one
|
||||
cluster, not two independent clusters.
|
||||
|
||||
**Fix (rig).** Added an `&akka-founder-healthcheck` (bash `/dev/tcp` probe of Akka port 4053; the image
|
||||
has no nc/curl) to site-a-1/site-b-1, and switched site-a-2/site-b-2 to `depends_on: { site-a-1:
|
||||
service_healthy }`. The founder now binds + forms its mesh before the partner starts, so the partner
|
||||
JOINS it. Verified: site-a-2/site-b-2 log `Welcome from <founder>` (joined), ServiceLevel 250/240.
|
||||
|
||||
---
|
||||
|
||||
## Evidence log
|
||||
|
||||
- **Leg 1** — join handshakes: central-1↔central-2, site-a-1↔site-a-2, site-b-1↔site-b-2; no node
|
||||
handshakes a node outside its pair. central *dials* site nodes over ClusterClient/telemetry (Akka
|
||||
association + gRPC), which is the split-topology transport, NOT gossip membership.
|
||||
- **Leg 2** — `Client.CLI redundancy` per endpoint: 4840 central-1=240, 4841 central-2=250, 4842
|
||||
site-a-1=250, 4843 site-a-2=240, 4844 site-b-1=250, 4845 site-b-2=240. One Primary per pair.
|
||||
@@ -18,7 +18,7 @@
|
||||
{"id": 6, "subject": "Task 6: compiled transport-mode defaults", "classification": "small", "status": "resolved-no-flip", "note": "DECIDED not to flip — blast-radius assessment: high-risk (breaks all integration tests + appsettings profiles + docker-dev site nodes), low-value (Task 5 validator already enforces split-correctness fail-closed). Documented in plan Task 6 + Configuration.md."},
|
||||
{"id": 7, "subject": "Task 7: rewrite docker-dev rig into three 2-node meshes", "classification": "standard", "status": "completed", "commit": "741ab97b", "note": "3 meshes, per-pair self-first seeds, cluster roles, transports flipped (ClusterClient/Grpc), pair-local secrets note; docker compose config validates; seed SQL already correct (GrpcPort=4056)"},
|
||||
{"id": 8, "subject": "Task 8: docs sweep — status tables, caveat removal, pair-local secrets note", "classification": "small", "status": "completed", "commit": "8a173bba", "note": "all 7 edits (Redundancy/Config/CLAUDE/2 plans/IManualFailoverService/ClusterRedundancy.razor); subagent died at commit step, edits reviewed+committed by controller; full-solution build clean (0 errors)"},
|
||||
{"id": 9, "subject": "Task 9: live gate — three meshes, per-pair redundancy, cross-mesh transports","classification": "high-risk", "status": "in-progress", "note": "local OrbStack docker up; full-solution build verified clean; building images"}
|
||||
{"id": 9, "subject": "Task 9: live gate — three meshes, per-pair redundancy, cross-mesh transports","classification": "high-risk", "status": "completed", "note": "PASSED — all 8 legs green (docs/plans/2026-07-24-mesh-phase6-live-gate.md). Caught + fixed-forward 3 defects: (1) product boot-crash StackOverflow — cluster-redundancy singleton resolved IClusterRoleInfo eagerly inside the AddAkka lambda while the ActorSystem was building (fix 3a4ed8dd: derive scope from AkkaClusterOptions); (2) rig — site nodes crashed on LDAP options (no Security__Ldap__* block); (3) rig — site pairs split-brained on simultaneous start (both self-first seeds), fixed with a founder healthcheck + service_healthy gating (2+3 in 792f28ec). Legs: 3 isolated meshes, one Primary/pair 250/240, deploy sealed acks=6 over per-cluster ClusterClient, telemetry gRPC :4056 up + reconnect, reconciler own-cluster-scoped, secrets pair-local, split validator refuses Dps, failover+auto-down-1v1 survival proven. Master 792f28ec."}
|
||||
],
|
||||
"lastUpdated": "2026-07-24T00:00:00Z"
|
||||
"lastUpdated": "2026-07-24T11:10:00Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Per-cluster mesh Phase 7 — failover drills + closing live gates
|
||||
|
||||
> **Final phase of the per-cluster mesh program.** Run the failover drill per pair type, close the two
|
||||
> outstanding live gates (auto-down 1-vs-1 crash-the-oldest; self-first cold-start-alone), re-verify the
|
||||
> manual-failover button, and ship a one-page co-located operator runbook.
|
||||
>
|
||||
> Runs against merged master on the docker-dev three-mesh rig (same rig as the Phase 6 gate).
|
||||
|
||||
## Scope clarified by recon
|
||||
|
||||
- **Failover mechanism is the same for every pair**: the oldest Up `driver` member leaves/dies, the peer
|
||||
becomes oldest, takes the `cluster-{ClusterId}` redundancy singleton, and advertises ServiceLevel 250.
|
||||
- **Manual-failover button (`IManualFailoverService`) is MAIN-scoped by construction** — it acts on the
|
||||
local node's `Cluster.State` (graceful `Leave` of the oldest Up driver), and the AdminUI runs only on
|
||||
the central (MAIN) pair. Site pairs are **driver-only, no UI**; they fail over via **auto-down** (kill)
|
||||
only. This is the documented pair-local caveat from Phase 6, not a gap to fix.
|
||||
- **Graceful (`docker stop`, SIGTERM → CoordinatedShutdown → cluster Leave) vs. ungraceful (`docker kill`,
|
||||
SIGKILL → peer auto-downs)** are the two failover paths. Manual failover = graceful Leave; a crashed
|
||||
node = auto-down.
|
||||
|
||||
## Drills
|
||||
|
||||
| # | Drill | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| D1 | MAIN graceful failover (manual-failover path) — Primary leaves, peer takes over | ✅ PASS | AdminUI "Trigger failover" on `/clusters/MAIN/redundancy`: central-2 "Exiting completed" → central-1 became Primary (250) → central-2 restarted, "Welcome from central-1" (rejoined, no split). Roles swapped 250↔240 |
|
||||
| D2 | SITE-A auto-down failover, BOTH directions (kill Primary, then kill new Primary) | ✅ PASS | leg 8 killed site-a-1→site-a-2 Primary; D2 killed site-a-2→site-a-1 Primary (250), survived alone |
|
||||
| D3 | SITE-B auto-down failover (crash-the-oldest) | ✅ PASS | killed site-b-1 (Primary/oldest) → site-b-2 "Member removed [site-b-1]", BecomingOldest→Oldest, 250 |
|
||||
| D4 | Gate (a): auto-down 1-vs-1 crash-the-oldest — survivor stays Up, does not self-down | ✅ PASS | every survivor across D2/D3/D5/leg8 stayed Up (e.g. site-a-1 "Up 49 minutes", site-b-2 "Up 56 minutes") — no self-down. Closes the gate deferred since Phase 0a |
|
||||
| D5 | Gate (b): self-first cold-start-alone — a node boots ALONE (partner down) and forms its mesh | ✅ PASS | stopped both site-b, started only site-b-1: "JOINING itself … forming a new cluster" → leader → Up → serves 250 as sole Primary |
|
||||
| D6 | Recovery — a downed/restarted node rejoins its pair as Secondary (240) | ✅ PASS | central-2, site-a-2, site-b-2 all restarted → "Welcome from <founder>" → rejoined 240 |
|
||||
|
||||
**Phase 7 exit gate: MET.** All 6 drills pass on every pair type. Final fleet: three healthy 2-node
|
||||
pairs, each one Primary (250) + one Secondary (240). Roles are wherever the last drill left them — each
|
||||
pair has exactly one Primary, which is what matters.
|
||||
|
||||
## Findings / notes
|
||||
|
||||
- **Manual-failover button is MAIN-only, by construction** (acts on the local `Cluster.State`; AdminUI
|
||||
runs only on central). The UI now correctly states "this application Cluster's own Primary … each
|
||||
cluster's redundant pair runs its own independent 2-node Akka mesh" (Phase 6 text, verified live).
|
||||
Site pairs (driver-only, no UI) fail over via auto-down (crash) only — there is no manual-failover
|
||||
surface for them, and that is correct for the topology.
|
||||
- **Graceful failover restarts the node in-place and it rejoins** (no split), because a single peer that
|
||||
is already Up answers the restarting node's InitJoin. The **simultaneous cold-start** split (Phase 6
|
||||
finding) only bites when BOTH pair nodes start from nothing at once — see the runbook mitigation.
|
||||
- **Carried Phase-6 finding — simultaneous cold-start of both pair VMs — NOW CLOSED by a product guard
|
||||
(`279d1d0f`).** On the real co-located VMs a site's two VMs can power-cycle together; two self-first
|
||||
seeds can then each form a 1-node cluster (split brain, two Primaries in one pair). Two mitigations now
|
||||
exist: (1) **operational** — stagger the two VMs' service-manager start / start the founder first (the
|
||||
docker-dev `depends_on: service_healthy` serialization, still used by site-b); (2) **product** — the
|
||||
opt-in `Cluster:BootstrapGuard` (default off), which makes the lower-address node the founder and the
|
||||
higher node probe-then-join, arbitrating the race inside the join decision. Live-gated on the site-a
|
||||
pair (guard on, serialization removed): simultaneous start → 250/240, no split; higher-node cold-start
|
||||
-alone → self-forms → 250. See `docs/Redundancy.md` §"Bootstrap guard". The guard does NOT need the
|
||||
compose `depends_on` that production hardware lacks — it is the production-faithful fix.
|
||||
|
||||
---
|
||||
|
||||
## Operator runbook — co-located OtOpcUa + ScadaBridge site failover (one page)
|
||||
|
||||
**Topology.** Each site = 2 Windows VMs. Each VM runs **one OtOpcUa driver node** (`driver,cluster-SITE-X`,
|
||||
Akka `:4053`, OPC UA `:4840`) **and one ScadaBridge site node** — two independent 2-node Akka clusters
|
||||
sharing the hardware, never a shared mesh. Central = 2 VMs, each an OtOpcUa `admin,driver,cluster-MAIN`
|
||||
node (hosts the AdminUI) + a ScadaBridge central node.
|
||||
|
||||
**Normal state.** Each pair: one node Primary (OPC UA ServiceLevel **250**), one Secondary (**240**).
|
||||
Check from any OPC UA client: `otopcua-cli redundancy -u opc.tcp://<node>:4840`.
|
||||
|
||||
**Planned failover (move the Primary off a VM — e.g. for patching):**
|
||||
- *Central (MAIN) pair:* AdminUI → **Clusters → MAIN → Redundancy → Trigger failover → Confirm**. The
|
||||
Primary gracefully leaves, restarts, rejoins as Secondary; its peer becomes Primary (250) in a few
|
||||
seconds. OPC UA clients re-select the new Primary automatically.
|
||||
- *Site pairs (SITE-A/SITE-B):* no UI (driver-only). Stop the OtOpcUa service on the Primary VM
|
||||
(graceful SIGTERM) — the peer auto-downs it and becomes Primary. Restart the service to rejoin.
|
||||
|
||||
**Unplanned failover (a VM dies).** The peer detects the loss (~10-15 s: failure detector + auto-down),
|
||||
removes the dead node, takes the redundancy singleton, and advertises 250. **A 1-node pair keeps
|
||||
serving** (auto-down 1-vs-1 survival — verified). Because the VM is shared, **both products fail over
|
||||
together**: confirm the ScadaBridge site node on the surviving VM also took over (its own runbook).
|
||||
|
||||
**Recovery.** Bring the dead VM back. The OtOpcUa node cold-starts, finds its peer Up, and rejoins as
|
||||
Secondary (240) — "Welcome from <peer>" in its log. No manual step.
|
||||
|
||||
**⚠️ Both VMs down at once (site power event).** Start the VMs **staggered**, or bring the **designated
|
||||
founder VM up first** and wait for its OtOpcUa node to reach ServiceLevel 250 before starting the second
|
||||
VM. Starting both OtOpcUa services simultaneously can split the pair into two 1-node clusters (two
|
||||
Primaries). If that happens: stop the OtOpcUa service on the non-founder VM, confirm the founder is the
|
||||
lone Primary (250), then restart the non-founder — it rejoins as Secondary.
|
||||
|
||||
**Health checks.**
|
||||
- ServiceLevel per node: `otopcua-cli redundancy -u opc.tcp://<node>:4840` (250 Primary / 240 Secondary).
|
||||
- MAIN membership + Primary: AdminUI → Clusters → MAIN → Redundancy.
|
||||
- A pair showing **250/250** = split brain — apply the "both VMs down" recovery above.
|
||||
- A pair showing **240/240 or a lone 240** = no Primary elected — check the singleton host is Up.
|
||||
@@ -0,0 +1,729 @@
|
||||
# Modbus RTU Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a Modbus **RTU-over-TCP** transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing `ModbusDriver`, selected by a new `Transport` config field — reusing every codec/planner/health/materialisation surface unchanged.
|
||||
|
||||
**Architecture:** Purely additive behind the existing `IModbusTransport` seam. The application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU — only the wire framing differs (`[addr][PDU][CRC-16]`, no MBAP, no TxId). Work = a CRC-16 helper + a length-less FC-aware framer + one new socket transport that composes a socket lifecycle extracted from `ModbusTcpTransport` + a transport-mode selector on the config + one AdminUI field. **Zero** changes to codecs/planner/health.
|
||||
|
||||
**Tech Stack:** existing `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (+ `.Contracts`, `.Addressing`), xUnit + Shouldly, `pymodbus[simulator]==3.13.0` Docker fixture (RTU-framed-TCP profile; stdlib fallback server modelled on `exception_injector.py`), Blazor `ModbusDriverForm.razor` (AdminUI). No new NuGet dependency.
|
||||
|
||||
**Source design:** docs/plans/2026-07-15-modbus-rtu-driver-design.md
|
||||
**Program design:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §7 fixtures) — this is **Wave 1**.
|
||||
**Progress tracker:** docs/plans/2026-07-24-driver-expansion-tracking.md
|
||||
|
||||
## Scope (P1 shippable v1 only)
|
||||
|
||||
RTU-over-TCP via a serial→Ethernet gateway (Moxa/Digi/Lantronix), RTU CRC-16 (poly `0xA001`) + FC-aware length framing, a `Transport` selector (`Tcp` | `RtuOverTcp`) on the Modbus config, the `rtu_over_tcp` pymodbus docker profile, and the AdminUI selector. Read **and** write supported (same as TCP).
|
||||
|
||||
## Deferred / out of scope
|
||||
|
||||
- **Direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped (user, 2026-07-15). RTU buses are reached exclusively via a serial→Ethernet gateway. Design record kept in **design §2b / §9 P2**; the `ModbusRtuFraming` built here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do **not** number-squat an `Rtu` enum member for it.
|
||||
- **Modbus ASCII** — a third framing (`:`-delimited hex + LRC); would be another `IModbusTransport` if ever needed (design §2c). Not planned.
|
||||
- **Per-tag `TagConfig` changes** — none. `ModbusTagDefinition`/`ModbusTagDto` already carry the per-tag `UnitId` override that makes an RS-485 multi-drop bus work; the read planner already refuses to coalesce across UnitIds. Additions are **driver-level only** (design §4).
|
||||
- **`ModbusDriver.BuildSlaveHostName` change** — NOT needed. It already emits `host:port/unit{n}`, the correct per-slave resilience key for a gateway (design §6). The design §1 table listed it only for the descoped serial (COM) case.
|
||||
|
||||
## Cross-cutting rules (program §3.1 — mandatory)
|
||||
|
||||
- **Enum-serialization trap** — `Transport` MUST round-trip as a **name string**, never a number, on both the AdminUI serializer (`ModbusDriverForm._jsonOpts` already carries `JsonStringEnumConverter`) and the factory (DTO field typed `string?`, parsed via the existing `ParseEnum<T>` — mirror `Family`/`MelsecSubFamily`; **do not** type the DTO field as the enum). Guarded by an explicit `"transport":"RtuOverTcp"` string assertion (design §5).
|
||||
- **Per-op deadline (R2-01)** — the RTU transport MUST run every transaction under a linked-CTS `CancelAfter(Options.Timeout)` — a frozen gateway must never wedge a poll. There is **no MBAP length field to trust**; FC-aware sizing is the only length signal, so getting it right (or timing out) is the top correctness risk (design §7).
|
||||
- **Single-flight mandatory on RTU** — no TxId means at most one transaction on the bus at a time. Keep the `_gate` `SemaphoreSlim` in the new transport.
|
||||
- **Ctor is connection-free** — the transport constructor must not connect; all I/O happens in `ConnectAsync`/`SendAsync`.
|
||||
- **Live-verify discipline** — the picker/editor + deploy path get a docker-dev `/run` before trust (Task 10).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Add the `ModbusTransportMode` enum
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 3
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs`
|
||||
|
||||
The enum lives in `.Contracts` (backend-dep-free), namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` — same namespace as `ModbusDriverOptions`.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_is_the_default_zero_member()
|
||||
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
|
||||
|
||||
[Fact]
|
||||
public void Exactly_two_members_ship_in_v1()
|
||||
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL** (type does not exist / does not compile):
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportModeTests"`
|
||||
|
||||
**Step 3 — minimal impl:**
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
|
||||
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
|
||||
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
|
||||
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
|
||||
/// do not number-squat it.
|
||||
/// </summary>
|
||||
public enum ModbusTransportMode
|
||||
{
|
||||
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
|
||||
Tcp,
|
||||
|
||||
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
|
||||
RtuOverTcp,
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ModbusCrc` — CRC-16 (poly 0xA001) helper + golden vectors
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 3
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs`
|
||||
|
||||
CRC-16/MODBUS: reflected poly `0xA001`, init `0xFFFF`, appended **low byte first**. This is the top-of-stack of the length-less framer — golden vectors are the whole point.
|
||||
|
||||
**Step 1 — failing test** (table-driven against published Modbus CRC vectors):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusCrcTests
|
||||
{
|
||||
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
|
||||
// on the wire it is appended low-byte-first.
|
||||
[Theory]
|
||||
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
|
||||
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
|
||||
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
|
||||
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
|
||||
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
|
||||
=> ModbusCrc.Compute(frame).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void AppendLowByteFirst_writes_lo_then_hi()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var withCrc = ModbusCrc.Append(body);
|
||||
withCrc.Length.ShouldBe(body.Length + 2);
|
||||
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
|
||||
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusCrcTests"`
|
||||
|
||||
**Step 3 — minimal impl** (`ModbusCrc.cs`): a `static ushort Compute(ReadOnlySpan<byte>)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan<byte> body)` returning `body + [crc & 0xFF, crc >> 8]`. ~30 lines. If a golden vector disagrees, fix the CRC math — never the vector.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ModbusRtuFraming` — ADU build + FC-aware length-less deframe
|
||||
|
||||
**Classification:** high-risk (framing)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs`
|
||||
|
||||
The one genuinely new correctness risk (design §2a/§7): RTU frames carry **no length field**, so response size is parsed from the function code. Read `addr(1)`+`fc(1)`, then: **exception** (`fc & 0x80`) → `excCode(1)+CRC(2)`; **reads FC01/02/03/04** → `byteCount(1)` then `byteCount + CRC(2)`; **write echoes FC05/06/15/16** → fixed `4 + CRC(2)`. Validate trailing CRC, strip `addr`+`CRC`, return the bare PDU `[fc, ...data]`. CRC mismatch / truncation → `ModbusTransportDesyncException`; exception PDU (after CRC-validate) → `ModbusException`. Test directly against an in-memory `MemoryStream` of canned bytes.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusRtuFramingTests
|
||||
{
|
||||
private static MemoryStream Canned(params byte[] frame) => new(frame);
|
||||
|
||||
[Fact]
|
||||
public void BuildAdu_prefixes_unit_and_appends_crc()
|
||||
{
|
||||
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
|
||||
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC03_returns_bare_pdu()
|
||||
{
|
||||
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
|
||||
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
|
||||
var frame = ModbusCrc.Append(body);
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
// addr=01 fc=0x83 exc=0x02 + CRC
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
await using var s = Canned(frame);
|
||||
var ex = await Should.ThrowAsync<ModbusException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.FunctionCode.ShouldBe((byte)0x03);
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_bad_crc_throws_desync()
|
||||
{
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
frame[^1] ^= 0xFF; // corrupt CRC high byte
|
||||
await using var s = Canned(frame);
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
|
||||
{
|
||||
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuFramingTests"`
|
||||
|
||||
**Step 3 — minimal impl** (`ModbusRtuFraming.cs`): `static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task<byte[]> ReadResponsePduAsync(Stream, byte expectedUnit, CancellationToken)` doing the FC-aware read described above with a `ReadExactlyAsync` helper (mirror `ModbusTcpTransport.ReadExactlyAsync`). Reuse `ModbusException` + `ModbusTransportDesyncException` (both already in the project). Keep framing byte-stream-agnostic (no socket knowledge) so a future serial transport can reuse it.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (behaviour-preserving)
|
||||
|
||||
**Classification:** high-risk (transport / concurrency)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 1, Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs`
|
||||
|
||||
Mechanical refactor: move the IPv4-preference connect (`ConnectAsync`), `EnableKeepAlive`/`ClampToWholeSeconds`, `ConnectWithBackoffAsync`, `TearDownAsync`, and the `_lastSuccessUtc`/idle-disconnect tracking out of `ModbusTcpTransport` into a reusable `ModbusSocketLifecycle` that exposes the current `NetworkStream`. `ModbusTcpTransport` keeps its MBAP `SendOnceAsync` and composes the lifecycle. **Behaviour must be byte-for-byte unchanged** — the existing `ModbusTcpReconnectTests` + `ModbusConnectionOptionsTests` are the regression net; run the whole Modbus.Tests suite green before committing.
|
||||
|
||||
**Step 1 — failing test** (pins the extracted surface; `ClampToWholeSeconds` moves with it):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusSocketLifecycleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
|
||||
[InlineData(2.0, 2)]
|
||||
[InlineData(-5.0, 1)] // negative clamps to 1
|
||||
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
|
||||
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_dead_port_surfaces_socket_failure()
|
||||
{
|
||||
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
|
||||
autoReconnect: false);
|
||||
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
|
||||
life.ConnectAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusSocketLifecycleTests"`
|
||||
|
||||
**Step 3 — minimal impl:** create `ModbusSocketLifecycle` holding `_host/_port/_timeout/_autoReconnect/_keepAlive/_idleDisconnect/_reconnect`, the `TcpClient`/`NetworkStream`, and `_lastSuccessUtc`; expose `ConnectAsync`, `ConnectWithBackoffAsync`, `TearDownAsync`, `Stream` (current), `MarkSuccess()`, `ShouldReconnectForIdle()`, and the `static int ClampToWholeSeconds`. Re-point `ModbusTcpTransport` to delegate connect/reconnect/teardown/idle to it while keeping MBAP `SendOnceAsync` in place. Move (don't duplicate) `ClampToWholeSeconds` — update its one internal caller/test reference.
|
||||
|
||||
**Step 4 — run & expect PASS** — then run the **whole** suite to prove no regression:
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests`
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ModbusRtuOverTcpTransport` — RTU framing over the socket lifecycle
|
||||
|
||||
**Classification:** high-risk (framing / transport / concurrency)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs`
|
||||
|
||||
`IModbusTransport` implementation: composes `ModbusSocketLifecycle` (Task 3) for connect/reconnect/keepalive/idle and `ModbusRtuFraming` (Task 2) for send/receive. Delta from `ModbusTcpTransport`: CRC framing instead of MBAP, **no TxId**. Keep the `_gate` `SemaphoreSlim` single-flight (mandatory — no TxId), the single reconnect-retry shape, and a linked-CTS `CancelAfter(Options.Timeout)` per-op deadline (design §7, R2-01). **Seam choice for unit test:** add an `internal` test ctor accepting a pre-connected `Stream` (an in-memory duplex fake) that bypasses `ConnectAsync` — this is "the fake one level below the `IModbusTransport` fakes" the design §8 calls for, and lets the send/receive orchestration + single-flight + deadline be tested with no socket.
|
||||
|
||||
**Step 1 — failing test** (duplex fake: canned response stream + capture of written request bytes):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusRtuOverTcpTransportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
|
||||
{
|
||||
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
|
||||
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
|
||||
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
|
||||
{
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
await Should.ThrowAsync<ModbusException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
|
||||
{
|
||||
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
```
|
||||
(The `CapturingDuplexStream` test double — a `Stream` that records writes and replays `respondBytes` on read, or blocks forever when `stall` — lives in the test file.)
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuOverTcpTransportTests"`
|
||||
|
||||
**Step 3 — minimal impl:** production ctor takes the same connection params as `ModbusTcpTransport` and builds a `ModbusSocketLifecycle`; `internal static ForTest(Stream, TimeSpan)` injects a pre-connected stream. `SendAsync`: `_gate.WaitAsync`; idle-reconnect check (skipped in test seam); write `ModbusRtuFraming.BuildAdu(unitId, pdu)`, flush, `ModbusRtuFraming.ReadResponsePduAsync(...)` under a linked `CancelAfter(_timeout)`; timeout-vs-caller-cancel distinction + teardown mirror `ModbusTcpTransport.SendOnceAsync`; single reconnect-retry on socket-level failure.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ModbusTransportFactory.Create` — switch on `Transport`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 6
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs`
|
||||
|
||||
One place that maps `ModbusDriverOptions` → the right `IModbusTransport`, consumed by both the driver default closure (Task 7) and the probe (Task 7). `Tcp` → `ModbusTcpTransport`, `RtuOverTcp` → `ModbusRtuOverTcpTransport`; both get `Host/Port/Timeout/AutoReconnect/KeepAlive/IdleDisconnect/Reconnect`.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_mode_builds_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void RtuOverTcp_mode_builds_rtu_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_options_build_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
}
|
||||
```
|
||||
(Depends on Task 6's `Transport` property existing on `ModbusDriverOptions` — sequence Task 6 or add the property first. If Task 5 lands before Task 6, add the property in this task instead.)
|
||||
|
||||
**Step 2 — run & expect FAIL.**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportFactoryTests"`
|
||||
|
||||
**Step 3 — minimal impl:** `static IModbusTransport Create(ModbusDriverOptions o)` = `switch (o.Transport) { RtuOverTcp => new ModbusRtuOverTcpTransport(...), _ => new ModbusTcpTransport(...) }`, passing the shared connection params.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `Transport` to options + DTO + factory (string-enum guard)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs`
|
||||
|
||||
Add `public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;` to `ModbusDriverOptions`. On the DTO: add `public string? Transport { get; init; }` (typed **`string?`**, per the Modbus factory pattern — design §5) and map it with the existing `ParseEnum<ModbusTransportMode>(...)` helper, defaulting to `Tcp` when null. The guard test asserts the string wire form.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportConfigRoundTripTests
|
||||
{
|
||||
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
|
||||
private static readonly JsonSerializerOptions _adminOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transport_serializes_as_a_string_not_a_number()
|
||||
{
|
||||
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(opts, _adminOpts);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
|
||||
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_binds_RtuOverTcp_from_string_config()
|
||||
{
|
||||
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_defaults_Transport_to_Tcp_when_omitted()
|
||||
{
|
||||
const string json = """{ "host": "10.0.0.10" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportConfigRoundTripTests"`
|
||||
|
||||
**Step 3 — minimal impl:** add the `Transport` property (options) + DTO `string?` field + factory mapping `Transport = dto.Transport is null ? ModbusTransportMode.Tcp : ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport")`.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the driver default closure + probe to `ModbusTransportFactory`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs`
|
||||
|
||||
Replace the driver ctor's hardcoded `new ModbusTcpTransport(...)` default closure with `o => ModbusTransportFactory.Create(o)`. In the probe, replace the hardcoded `new ModbusTcpTransport(host, port, timeout, autoReconnect: false)` (line ~77) with a factory build carrying `Transport` (parse it into `ProbeTarget` from the DTO), `autoReconnect: false`. Now an `RtuOverTcp`-authored config probes over RTU framing — matching how it will actually run.
|
||||
|
||||
**Step 1 — failing test** (the default closure honours the mode; the probe carries it):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportWiringTests
|
||||
{
|
||||
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
|
||||
{
|
||||
using var driver = new ModbusDriver(opts, "wire-test");
|
||||
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
|
||||
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
return factory(opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_tcp_transport_for_Tcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
}
|
||||
```
|
||||
(If `_transportFactory` reflection proves brittle, assert instead that a `RtuOverTcp` driver initialised against an unreachable host degrades without ever constructing an MBAP frame — but the closure-reflection test is the tightest.)
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportWiringTests"`
|
||||
|
||||
**Step 3 — minimal impl:** driver ctor default = `?? (o => ModbusTransportFactory.Create(o))`; probe: add `ModbusTransportMode Transport` to `ProbeTarget`, parse `dto.Transport` via `ParseEnum`, and build the probe transport through `ModbusTransportFactory.Create(new ModbusDriverOptions { Host, Port, Timeout, Transport, AutoReconnect = false })`.
|
||||
|
||||
**Step 4 — run & expect PASS** (+ run the full Modbus.Tests suite green).
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: AdminUI — `Transport` selector on `ModbusDriverForm`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs`
|
||||
|
||||
Add a `Transport` `<InputSelect>` to the **Protocol** panel (design's target `ModbusDriverPage.razor` is retired; `ModbusDriverForm.razor` is the live host — it serializes `ModbusDriverOptions` directly through `_jsonOpts`, which already carries `JsonStringEnumConverter`, so `Transport` emits as a name string automatically; `Transport` is **not** an endpoint key so it is not stripped by `GetConfigJson`). Add `public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;` to `FormModel`, map it in `FromOptions`/`ToOptions`. Host/Port stay on `ModbusDeviceForm` — a `RtuOverTcp` gateway uses the same `host:port` shape. Extend the existing `ModbusDriverFormModelTests` (round-trip + string-enum guard).
|
||||
|
||||
**Step 1 — failing test** (append to `ModbusDriverFormModelTests`):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Round_trip_preserves_Transport_mode()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(
|
||||
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
|
||||
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverFormModelTests"`
|
||||
|
||||
**Step 3 — minimal impl:** add the `Transport` `FormModel` property + `FromOptions`/`ToOptions` mapping, and an `<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync">` looping `Enum.GetValues<ModbusTransportMode>()` in the Protocol panel, with a form-text hint: "RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode."
|
||||
|
||||
**Step 4 — run & expect PASS.** (AdminUI has no bUnit — the razor binding itself is proven live in Task 10; this model test is the reflection substitute per the AdminUI live-verify memory.)
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Docker `rtu_over_tcp` fixture + RTU-over-TCP integration test
|
||||
|
||||
**Classification:** standard (new component / multi-file)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json`
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs`
|
||||
- Modify (only if the pymodbus RTU-framer confirmation fails): `Docker/Dockerfile` + `Docker/rtu_over_tcp_server.py`
|
||||
|
||||
**FIRST sub-step — confirm the unknown (design §8 #1):** verify the pymodbus simulator actually exposes the RTU framer on a TCP server. The checked-in `profiles/standard.json` already sets `server_list.srv.framer: "socket"`, so the knob exists; the RTU profile sets `"framer": "rtu"` (comm stays `"tcp"`). Bring it up and probe with the new driver:
|
||||
```bash
|
||||
lmxopcua-fix sync modbus
|
||||
lmxopcua-fix up modbus rtu_over_tcp
|
||||
# then run the integration test (below) against MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021
|
||||
```
|
||||
If pymodbus 3.13's simulator does **not** honour `framer=rtu` on a TCP server (garbled/MBAP-framed replies), **fall back** to a stdlib `rtu_over_tcp_server.py` modelled on the existing `exception_injector.py` (same asyncio TCP-server shape, but frame with `[addr][PDU][CRC-16]` via a Python CRC-16/MODBUS instead of the MBAP header) + a `COPY rtu_over_tcp_server.py` line in the Dockerfile and a `command:` override on the service. Record which path was taken in the test-class summary.
|
||||
|
||||
Fixture details: bind host port **`5021`** (NOT the shared `:5020` — the `rtu_over_tcp` service must co-run with `standard` and sidestep the shared-port stale-container trap). Add `labels: { project: lmxopcua }` per the program fixture convention (existing services carry none — `lmxopcua-fix sync` normally owns the label host-side, but add it in-file for this service so it is discoverable). `ModbusRtuOverTcpFixture` mirrors `ModbusSimulatorFixture` but reads `MODBUS_RTU_SIM_ENDPOINT` (default `10.100.0.35:5021`) with the same skip-clean pattern.
|
||||
|
||||
**Step 1 — failing test** (`ModbusRtuOverTcpTests.cs` — read + write round-trip over RTU-over-TCP):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
[Collection(ModbusRtuOverTcpCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "RtuOverTcp")]
|
||||
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, Address: 5,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-rtu-int");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u); // Good
|
||||
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition("HR200", ModbusRegion.HoldingRegisters, Address: 200,
|
||||
DataType: ModbusDataType.UInt16, Writable: true),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-rtu-int-w");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await driver.WriteAsync([new WriteRequest("HR200", (ushort)4242)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writes[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
|
||||
back[0].Value.ShouldBe((ushort)4242);
|
||||
}
|
||||
}
|
||||
```
|
||||
(Seed `rtu_over_tcp.json` with the same `HR[0..31]=address-as-value` + `HR[200..209]=scratch` layout as `standard.json` so these addresses resolve.)
|
||||
|
||||
**Step 2 — run & expect FAIL / SKIP** without the fixture, then bring it up:
|
||||
```bash
|
||||
lmxopcua-fix sync modbus && lmxopcua-fix up modbus rtu_over_tcp
|
||||
MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests --filter "FullyQualifiedName~ModbusRtuOverTcpTests"
|
||||
```
|
||||
|
||||
**Step 3 — minimal impl:** the `rtu_over_tcp.json` profile + compose service (+ stdlib server fallback only if the framer confirmation failed) + the fixture class. Iterate until both facts hold on the wire.
|
||||
|
||||
**Step 4 — run & expect PASS** against the live fixture.
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Live `/run` verification on docker-dev
|
||||
|
||||
**Classification:** standard (live gate)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify (docs only): `docs/plans/2026-07-24-driver-expansion-tracking.md` (record Wave-1 Modbus-RTU live-gate result)
|
||||
|
||||
No new code — the live-verify discipline (program §3.1) proves the Razor binding + deploy path that unit tests can't. Against the local docker-dev rig (AdminUI `http://localhost:9200`, login disabled — drive it yourself; do not wait for the user to sign in):
|
||||
|
||||
1. Bring up the `rtu_over_tcp` fixture (`lmxopcua-fix up modbus rtu_over_tcp`, host `:5021`).
|
||||
2. In `/raw`, author a Modbus device pointing Host/Port at the docker host `:5021`, and set the driver **Transport = RtuOverTcp** in the driver-config modal (the Task 8 selector). Run **Test Connect** — expect green (the probe now runs FC03 over RTU framing).
|
||||
3. Author a holding-register raw tag (e.g. `HR5`), reference it into a UNS equipment, and deploy.
|
||||
4. Read it back via Client.CLI against the local server, or the `/uns` value panel:
|
||||
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath-of-HR5>"`
|
||||
— expect Good quality + the seeded value.
|
||||
5. Write to a writable register (`HR200`) via Client.CLI `write` and confirm it round-trips.
|
||||
6. Record PASS/FAIL + evidence in the tracking doc.
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Ordering summary
|
||||
|
||||
Framer/CRC unit layer (Tasks 0–2) → transport (Tasks 3–4) → factory + config + driver/probe wiring (Tasks 5–7) → AdminUI selector (Task 8) → docker integration fixture (Task 9) → live `/run` (Task 10). Tasks 1 & 3 can run alongside Task 0; Task 5 alongside Task 6; Task 7 alongside Task 8. Commit after every task. DRY: `ModbusRtuFraming` and `ModbusSocketLifecycle` are each written once and composed; no codec/planner/health code is touched. YAGNI: no direct-serial, no ASCII, no per-tag config, no `BuildSlaveHostName` change.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
|
||||
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
|
||||
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
# MQTT / Sparkplug B Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ship a standard Equipment-kind `Mqtt` driver that ingests plain MQTT (P1) then Sparkplug B (P2) into the OtOpcUa dual-namespace address space, with a bespoke observation browser, typed AdminUI editor, and TLS+auth-enforced fixtures.
|
||||
**Architecture:** Three new `net10.0` projects mirror the OpcUaClient triad — `.Contracts` (transport-free options/DTOs/parser/enums), `.Driver` (subscribe-first `IDriver` holding one live MQTTnet-5 client for its whole lifetime, raising `OnDataChange` from the receive callback, with a hand-rolled reconnect loop since v5 dropped `ManagedMqttClient`), and `.Browser` (a passive `#`/birth observation-window `IBrowseSession`). Sparkplug B decodes vendored Eclipse Tahu protobuf via `Google.Protobuf`/`Grpc.Tools` over that same single client — no SparkplugNet.
|
||||
**Tech Stack:** MQTTnet v5 (MIT, .NET Foundation, ships `net10.0`), vendored Eclipse Tahu `sparkplug_b.proto` + `Google.Protobuf` (already pinned 3.34.1) + `Grpc.Tools` (already pinned 2.76.0, build-time), bespoke browser.
|
||||
**Source design:** docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
|
||||
|
||||
**Phases:** P1 (plain MQTT) = Tasks 0–14 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 15–26 (built on the P1 skeleton). Write-through (NCMD/DCMD/plain-publish) is deferred — see "Deferred / out of scope".
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting rules (apply to every task — program design §3.1)
|
||||
|
||||
- **Enum-serialization trap (systemic bug):** every JSON seam that (de)serializes `MqttDriverOptions` or a tag config — factory, probe, browser, **and** the AdminUI driver-config page + probe DTO — MUST share a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` + `PropertyNameCaseInsensitive=true` + `UnmappedMemberHandling=Skip`. `MqttMode`, `MqttPayloadFormat`, `protocolVersion` are enums; serialize them as **names**. Mirror `OpcUaClientDriverFactoryExtensions`.
|
||||
- **Per-op deadline (R2-01 frozen-peer lesson):** every network call (connect / subscribe / probe / rebirth-publish) has a bounded linked-CTS deadline. No unbounded waits on a dead broker.
|
||||
- **Ctor is connection-free:** `MqttDriver`/`MqttDriverBrowser` constructors touch no network; all connects happen in `InitializeAsync`/`OpenAsync` (the universal-browser `CanBrowse` throwaway-instance pattern depends on this).
|
||||
- **Secrets from env:** broker `password` is blank in committed JSON, supplied via env (mirror `ServerHistorian__ApiKey`). Never commit or log creds.
|
||||
- **Broker security — never ship anonymous/public-broker defaults:** default `useTls=true`, real auth. `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). Fixtures run auth+TLS.
|
||||
- **DriverType string is `"Mqtt"`** everywhere (driver page, probe, factory, editor map, validator, browser). One constant `DriverTypeNames.Mqtt`; grep to enforce (heed the `ModbusTcp`/`Modbus` mismatch lesson).
|
||||
- **Live-verify discipline:** Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev `/run` live-verify (Tasks 14 + 26).
|
||||
- **New-project csproj:** `net10.0`, `Nullable` + `ImplicitUsings` enabled, `TreatWarningsAsErrors=true` opted in per-csproj (not global).
|
||||
|
||||
---
|
||||
|
||||
## Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (this is the gate — the design's #1 dependency risk; nothing else starts until it is green)
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="MQTTnet" Version="5.x" />`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (temporary spike form — references `MQTTnet` only to prove the graph restores; the transport ref is removed in Task 1, `.Contracts` is transport-free)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project)
|
||||
|
||||
**Why first:** The design (§2.1, §10) makes this the top dependency risk — the repo uses central package management with `CentralPackageTransitivePinningEnabled` **deliberately OFF** (Roslyn 5.0.0/4.12.0 split, `Directory.Packages.props:103` comment + "Transitive pinning breaks Roslyn build" memory). We must prove **MQTTnet v5 restores and builds clean on net10 in this exact pinning configuration** before writing any driver code. Fresh-restore red-vs-local-green (the NU1903 memory) also means we validate a clean restore, not just an incremental one.
|
||||
|
||||
### Steps
|
||||
1. Confirm on NuGet the exact latest **MQTTnet v5** version carrying a `net10.0` TFM; pin that exact version in `Directory.Packages.props` (alphabetical position near `MessagePack`).
|
||||
2. Scaffold the `.Contracts` csproj (net10.0, nullable, implicit usings, TWAE) with a single `<PackageReference Include="MQTTnet" />` (no `Version` — central management supplies it). Add to `slnx`.
|
||||
3. Re-verify the two external-library record claims (design §2.1 checkbox): (a) MQTTnet v5 net10.0 TFM exists; (b) SparkplugNet still transitively pins MQTTnet 4.3.x with no net10.0 TFM. Record the confirmed versions in the tasks.json note. The hand-roll decision does not hinge on (b) — it is a confirm-the-record check.
|
||||
4. Prove a **clean** restore + build:
|
||||
```bash
|
||||
dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: no NU1605/NU1608 version-conflict, no NU1903 audit error
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: Build succeeded, 0 warnings (TWAE on)
|
||||
rm -rf ~/.nuget/packages/mqttnet && dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # fresh-restore proof
|
||||
```
|
||||
**Expected:** all three succeed with zero version-conflict / transitive-pin / audit errors. If restore fails on a 4/5 diamond or a missing net10 TFM, STOP — the hand-roll-Tahu decision (§2.1) is vindicated but the MQTTnet-5 pin itself is the blocker; resolve the exact version before proceeding.
|
||||
5. Commit:
|
||||
```bash
|
||||
git commit -am "feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (P1): `.Contracts` — enums + `MqttDriverOptions` DTO
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 2 depends on this)
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (drop the temporary MQTTnet ref from Task 0 — `.Contracts` is transport-free; reference only `Core.Abstractions`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs` (`Plain | SparkplugB`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs` (`Json | Raw | Scalar`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs` (`V311 | V500`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs` (broker conn + mode + `sparkplug`/`plain` sub-objects, §5.1)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj` (net10, xUnit + Shouldly)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Write the failing test — options round-trip enum-as-name:
|
||||
```csharp
|
||||
public sealed class MqttDriverOptionsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions J = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
|
||||
{
|
||||
const string json = """
|
||||
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
|
||||
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
|
||||
""";
|
||||
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
|
||||
o.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
o.UseTls.ShouldBeTrue();
|
||||
o.Sparkplug!.GroupId.ShouldBe("Plant1");
|
||||
o.Plain.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WritesEnumsAsNames_NotOrdinals()
|
||||
{
|
||||
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
|
||||
s.ShouldContain("\"Plain\"");
|
||||
s.ShouldNotContain("\"mode\":0");
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (types don't exist):
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
|
||||
```
|
||||
3. Implement the enums + `MqttDriverOptions` (with nested `MqttSparkplugOptions` / `MqttPlainOptions`) matching §5.1 defaults (`useTls=true`, `connectTimeoutSeconds=15`, `reconnectMin/MaxBackoffSeconds=1/30`). Wire the test csproj into `slnx`.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Contracts options DTO + enums (name-serialized)"` (+ session trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2 (P1): `.Contracts` — `MqttTagDefinition` + `MqttEquipmentTagParser` (plain)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 6 depends on the resolver)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs` (parsed per-tag descriptor — carries plain OR sparkplug variant fields; plain fields populated in P1)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs` (`TryParse(reference, out def)` + `Inspect(reference)`, mirrors `ModbusEquipmentTagParser` / `ModbusTagDefinitionFactory`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
|
||||
{
|
||||
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double","qos":1}""";
|
||||
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
|
||||
def!.Topic.ShouldBe("factory/oven/temp");
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TypoedPayloadFormat_RejectsStrict()
|
||||
=> MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_WildcardTopic_ReturnsWarning()
|
||||
=> MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: a leading `{` marks a TagConfig blob; use `TagConfigJson.TryReadEnumStrict` for `payloadFormat`/`dataType` (typo → `false` → `BadNodeIdUnknown` upstream). `def.Name = reference`. `Inspect` returns a deploy-time warning list for present-but-invalid enums / unparseable blobs / wildcard tag-topics. Leave Sparkplug-descriptor parsing as a stub the P2 tasks fill (`groupId`/`edgeNodeId`/`deviceId`/`metricName`).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): plain tag parser + strict-enum descriptor"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3 (P1): `.Driver` project + `MqttConnection` connect/TLS/auth (bounded)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 4 extends `MqttConnection`)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj` (net10, refs `.Contracts` + `Core.Abstractions` + `Core` + `MQTTnet`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (MQTTnet-5 client wrapper: build options from `MqttDriverOptions` incl. TLS + CA-pin + credentials; `ConnectAsync(ct)` under `connectTimeoutSeconds` linked-CTS)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — bounded connect against a dead endpoint fails fast (no hang), and TLS-option assembly honours the knobs. Use a closed loopback port for the deadline test:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ConnectAsync_DeadBroker_FailsWithinDeadline_NoHang()
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2 };
|
||||
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<Exception>(() => conn.ConnectAsync(CancellationToken.None));
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); // deadline honoured, not wedged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndValidatesChain()
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host="h", Port=8883, UseTls=true,
|
||||
AllowUntrustedServerCertificate=false, CaCertificatePath="/tmp/ca.pem" };
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions.UseTls.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `MqttConnection`: static `BuildClientOptions(opts, clientIdSuffix)` (host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS with `AllowUntrustedServerCertificate` → custom cert validator, `CaCertificatePath` → chain pin). `ConnectAsync(ct)` links `ct` with a `connectTimeoutSeconds` CTS. Ctor stores options only — connection-free.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (P1): `MqttConnection` hand-rolled reconnect loop (backoff + resubscribe)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (reconnect loop: exponential backoff `min→max`; on `DisconnectedAsync` schedule reconnect; on reconnect fire a `Reconnected` callback so the driver re-subscribes; expose `State` = Connected/Reconnecting/Faulted + `LastMessageAgeUtc`)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
|
||||
|
||||
**Why high-risk:** MQTTnet v5 dropped v4's `ManagedMqttClient` (§8) — there is no library-managed reconnect. Subscriptions do not survive a clean session; a reconnect that forgets to re-subscribe silently goes dark. Backoff that ignores its cap can hot-loop a dead broker.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — backoff schedule is bounded + monotone-to-cap, and a `Reconnected` event drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(10, 30)] // caps at max=30
|
||||
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
|
||||
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
|
||||
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnect_InvokesResubscribeCallback()
|
||||
{
|
||||
var conn = new MqttConnection(new MqttDriverOptions(), "t", null);
|
||||
var fired = 0; conn.Reconnected += () => { fired++; return Task.CompletedTask; };
|
||||
await conn.RaiseReconnectedForTest(); // test seam
|
||||
fired.ShouldBe(1);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: `NextBackoff(attempt, min, max)` pure; a reconnect worker that loops on disconnect with backoff, honours `cleanSession`/session-expiry, re-subscribes via the `Reconnected` callback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth. `State` transitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (P1): `LastValueCache` + `IReadable`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs` (`FullReference → DataValueSnapshot`, thread-safe)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — unseen reference returns a per-ref `GoodNoData`/uncertain snapshot (never throws the batch); a seeded ref returns last value:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Read_UnseenRef_ReturnsPerRefNoData_DoesNotThrow()
|
||||
{
|
||||
var c = new LastValueCache();
|
||||
var snap = c.Read("factory/oven/temp#$.value");
|
||||
snap.StatusCode.ShouldBe(StatusCodes.GoodNoData); // or Uncertain — per IReadable contract, per-ref status
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ThenRead_ReturnsLastValue()
|
||||
{
|
||||
var c = new LastValueCache();
|
||||
c.Update("k", DataValueSnapshot.Good(42.0, DateTime.UtcNow));
|
||||
c.Read("k").Value.ShouldBe(42.0);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `LastValueCache` (concurrent dict); `MqttDriver.ReadAsync` (Task 7) returns `references.Select(cache.Read)` — batch never throws, per-ref `StatusCode` carries "not yet observed".
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): last-value cache backing IReadable (per-ref no-data)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 6 (P1): `ISubscribable` — plain topic subscribe → `OnDataChange` + retained seed
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs` (registers `FullReference → MqttTagDefinition` via the shared `EquipmentTagRefResolver<MqttTagDefinition>`; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value at `jsonPath`/raw/scalar, raise `OnDataChange`; seed from retained message on subscribe)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (expose `SubscribeAsync(filters, ct)` under a bounded deadline + a message-received event)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire `OnDataChange` with the right `FullReference`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void OnMessage_MatchingTopic_RaisesDataChangeAtJsonPath()
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager();
|
||||
var handle = mgr.Register(new[] { """{"topic":"f/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double"}""" });
|
||||
string? gotRef = null; object? gotVal = null;
|
||||
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
|
||||
mgr.HandleMessage("f/oven/temp", """{"value":21.5}"""u8.ToArray(), retained: false);
|
||||
gotVal.ShouldBe(21.5);
|
||||
gotRef.ShouldContain("f/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnMessage_UnauthoredTopic_RaisesNothing() // chatty broker must not auto-provision
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager();
|
||||
mgr.Register(Array.Empty<string>());
|
||||
var fired = false; mgr.OnDataChange += (_, _) => fired = true;
|
||||
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
|
||||
fired.ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement using `EquipmentTagRefResolver<MqttTagDefinition>` (parse-once cache, as Modbus does). `HandleMessage` matches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updates `LastValueCache`, raises `OnDataChange`. Retained-flagged messages seed initial value. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 7 (P1): `MqttDriver` shell — `IDriver` + authored-only `ITagDiscovery` + `IHostConnectivityProbe`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 8/9 wire it)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable`; composes `MqttConnection` + `MqttSubscriptionManager` + `LastValueCache`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `DiscoverAsync` streams **only authored tags** into a capturing `IAddressSpaceBuilder`; plain-mode `RediscoverPolicy == Once`; `SupportsOnlineDiscovery == false`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
|
||||
{
|
||||
var driver = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.Plain }, "d", null);
|
||||
driver.SetAuthoredTagsForTest(new[] { """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""" });
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
b.Variables.Count.ShouldBe(1);
|
||||
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: `InitializeAsync` deserializes options (shared `JsonSerializerOptions`), builds + connects `MqttConnection`, subscribes the mode-appropriate filter. `ReinitializeAsync` applies deltas in place (never crash → Faulted). `ShutdownAsync` disconnects/disposes. `GetHealth` = Connected/Reconnecting/Faulted + last-message-age. `GetMemoryFootprint` = caches; `FlushOptionalCachesAsync` = no-op (birth/alias are correctness state — forbidden to flush; last-value backs `IReadable`). `DiscoverAsync` replays authored tags only; `RediscoverPolicy => Once` (plain). `IRediscoverable.OnRediscoveryNeeded` never fires in plain mode.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 8 (P1): `MqttDriverProbe` — CONNECT handshake
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs` (`IDriverProbe`, `DriverType => "Mqtt"`; parse config with the shared options, open a CONNECT under the timeout, green + latency on CONNACK-accepted, targeted error on refused/TLS/auth/timeout — mirrors `ModbusDriverProbe`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `DriverType` is `"Mqtt"`; a dead endpoint yields a failed (not thrown) probe result within the deadline:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
|
||||
{
|
||||
var r = await new MqttDriverProbe().ProbeAsync(
|
||||
"""{"host":"127.0.0.1","port":1,"useTls":false,"connectTimeoutSeconds":2}""", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the **shared** `JsonSerializerOptions` (enum-as-name).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttDriverProbe CONNECT handshake"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 9 (P1): Factory + `DriverTypeNames.Mqtt` + Host registration
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs` (`DriverTypeName = "Mqtt"`, shared `JsonSerializerOptions`, `Register(registry, loggerFactory)` — mirror `OpcUaClientDriverFactoryExtensions`)
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `public const string Mqtt = "Mqtt";` + append to the `All` list)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (add `Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)` near line 146; add `using MqttProbe = Driver.Mqtt.MqttDriverProbe;` + `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());` in `AddOtOpcUaDriverProbes` near line 124)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (ProjectReference the `.Driver` assembly)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `Register` binds the `"Mqtt"` type, and the factory builds an `MqttDriver` from JSON with string enums:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Register_ThenCreate_BuildsMqttDriver()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
MqttDriverFactoryExtensions.Register(registry);
|
||||
var d = registry.Create("Mqtt", "d1", """{"host":"h","port":1883,"mode":"Plain"}""");
|
||||
d.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverTypeName_MatchesConstant()
|
||||
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement + wire both host sites. Build the whole solution to confirm registration compiles:
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
|
||||
```
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 10 (P1): `.Browser` — bespoke `#`-observation browser (passive)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj` (net10, refs `.Contracts` + `Commons(.Browsing)` + `MQTTnet`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (`IDriverBrowser`, `DriverType => "Mqtt"`; `OpenAsync` connects with a `{clientId}-browse-{guid8}` suffix under a clamped 5–30 s budget; subscribes `#`/`{topicPrefix}#`; returns `MqttBrowseSession`; **publishes nothing**)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (`IBrowseSession` over a thread-safe accumulating observed tree; `RootAsync`/`ExpandAsync` split topic segments on `/`, leaf = `Kind=Leaf`; `AttributesAsync` = synthetic attribute w/ inferred type + last payload snippet; `DisposeAsync` disconnects best-effort)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert `RootAsync`/`ExpandAsync` build the segment tree and **no publish** occurs on any browse call:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp");
|
||||
var root = await s.RootAsync(CancellationToken.None);
|
||||
root.ShouldContain(n => n.BrowseName == "factory");
|
||||
var lvl2 = await s.ExpandAsync("factory", CancellationToken.None);
|
||||
lvl2.ShouldContain(n => n.BrowseName == "line3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseCalls_PublishNothing() // browse is read-only
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
await s.RootAsync(CancellationToken.None);
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the accumulating tree + passive `OpenAsync`. In P1 only the plain `#` path is live; leave a Sparkplug hook the P2 tasks fill (`Group→EdgeNode→Device→Metric` + `RequestRebirthAsync`).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): bespoke passive #-observation browser (plain)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 11 (P1): Register the bespoke browser (overrides universal fallback)
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 12
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();` alongside the existing two near line 75–76)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ProjectReference `.Browser`)
|
||||
|
||||
### Steps
|
||||
1. Add the registration + project reference. Registering for `DriverType="Mqtt"` overrides the universal fallback (`BrowserSessionService` resolves bespoke-first).
|
||||
2. Build: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — expect success.
|
||||
3. Commit: `git commit -am "feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 12 (P1): Typed AdminUI editor + validator (plain shape)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 11
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (thin typed model over a preserved `JsonObject` key bag; `FromJson`/`ToJson`/`Validate`, preserves unknown keys incl. history keys; carries a `Mode` field selecting sub-shape — P1 implements Plain `Validate`, Sparkplug stub filled in Task 24)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (`[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor)`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (`[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate()`)
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (copy the Modbus editor template — mode-switch at top, per-mode field group, client-side `Validate()`)
|
||||
- Create: `tests/Server/.../MqttTagConfigModelTests.cs` (co-locate with the existing AdminUI test project — verify path with `find tests/Server -name "*TagConfigModel*Tests.cs"`)
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — round-trip preserves unknown/history keys; plain `Validate` requires concrete topic + jsonPath-when-Json; re-derives `FullName`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void FromJson_ToJson_PreservesUnknownKeys()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","isHistorized":true}""");
|
||||
m.ToJson().ShouldContain("isHistorized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PlainWildcardTopic_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/+/c","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Validate_JsonWithoutPath_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeEmpty();
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the model (mirror `OpcUaClientTagConfigModel`); `ToJson` writes PascalCase `FullName` re-derived from descriptor (`{topic}#{jsonPath}` plain). Build the `.razor`. Register both map entries.
|
||||
4. Run — expect PASS + `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`.
|
||||
5. Commit: `git commit -am "feat(mqtt): typed tag editor + validator (plain mode)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 13 (P1): Mosquitto + JSON-publisher fixture (TLS+auth) + env-gated live suite
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (`eclipse-mosquitto` with **auth + TLS** on `:8883`, plain `:1883` for smoke only; a JSON-publisher container emitting `retain=true` JSON on a few topics — `mosquitto_pub` loop or `paho-mqtt` script; `project=lmxopcua` label applied host-side)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf` + password/cert material generator script (creds via env, never real secrets committed)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs` (category `LiveIntegration`, gated on `MQTT_FIXTURE_ENDPOINT` — skips clean when unset → macOS-offline-safe)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps
|
||||
1. Write the compose with **auth + TLS** (never anonymous). Add the JSON publisher with `retain=true`.
|
||||
2. Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence. `[SkippableFact]` reading `MQTT_FIXTURE_ENDPOINT`.
|
||||
3. Confirm offline skip:
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
|
||||
```
|
||||
4. Deploy the fixture to the docker host and run live (design §9):
|
||||
```bash
|
||||
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt
|
||||
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
|
||||
```
|
||||
5. Commit: `git commit -am "test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 14 (P1): Live `/run` verify on docker-dev — **P1 MILESTONE COMPLETE**
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (add the MQTT fixture endpoint to the Docker Workflow endpoint list: `10.100.0.35:1883/8883`, `MQTT_FIXTURE_ENDPOINT`)
|
||||
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT P1 code-complete)
|
||||
|
||||
### Steps
|
||||
1. On docker-dev (`:9200`, login disabled — run it yourself, do not defer): author an `Mqtt` driver (Plain mode) + a tag via the `/uns` picker (bespoke `#`-observation browser) or the typed editor; deploy.
|
||||
2. Confirm via Client.CLI the node carries live broker values:
|
||||
```bash
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
|
||||
```
|
||||
3. Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
|
||||
4. Commit: `git commit -am "docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
# ── P2: Sparkplug B ingest (builds on the P1 skeleton) ──
|
||||
|
||||
## Task 15 (P2): Vendor Tahu proto + `Grpc.Tools` codegen
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (P2 root)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto` (vendored Eclipse Tahu `sparkplug_b.proto`, pinned to a known Tahu commit with a provenance comment header)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (add `Google.Protobuf` + build-time `Grpc.Tools` (`PrivateAssets=all`); `<Protobuf Include="Protos/sparkplug_b.proto" GrpcServices="None" />` — message-only codegen, this repo's first in-repo protoc step)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — the generated `Payload`/`Metric` types exist and round-trip a hand-built payload:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void GeneratedPayload_RoundTrips()
|
||||
{
|
||||
var p = new Org.Eclipse.Tahu.Protobuf.Payload { Seq = 3 };
|
||||
p.Metrics.Add(new Org.Eclipse.Tahu.Protobuf.Payload.Types.Metric { Name = "Temperature", Alias = 5 });
|
||||
var back = Org.Eclipse.Tahu.Protobuf.Payload.Parser.ParseFrom(p.ToByteArray());
|
||||
back.Seq.ShouldBe(3ul);
|
||||
back.Metrics[0].Alias.ShouldBe(5ul);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (no generated types).
|
||||
3. Vendor the proto (provenance comment: Tahu commit hash + URL); wire `Grpc.Tools`. If in-repo protoc proves objectionable, fall back to checking in the generated C# with the same provenance comment (design §2.1). Build:
|
||||
```bash
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
|
||||
```
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 16 (P2): `SparkplugCodec` decode + golden payloads
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs` (decode `Payload`/`Metric` from wire bytes → a driver-side struct; encode NCMD deferred to `RebirthRequester` Task 20 / write-through P3)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin` + `ndata.bin` (golden byte vectors, generated once from a hand-built payload and committed)
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_ExtractsMetrics()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(File.ReadAllBytes("Golden/nbirth.bin"));
|
||||
payload.Seq.ShouldBe((byte)0);
|
||||
payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5 && m.DataType == SparkplugDataType.Float);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `SparkplugCodec.Decode`; generate the golden vectors in a one-off `[Fact(Skip="generator")]` or a small helper, commit the `.bin` files.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): SparkplugCodec decode + golden payload vectors"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 17 (P2): `SparkplugTopic` + `SparkplugDataType.ToDriverDataType`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs` (parse/format `spBv1.0/{group}/{type}/{node}[/{device}]`; `type` ∈ NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs` (enum + `ToDriverDataType()` per the §3.5 map: Int8→Int16, UInt8→UInt16, DataSet/Template unsupported, `*Array`→element+IsArray)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Parse_DeviceData_ExtractsAllSegments()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
|
||||
t.GroupId.ShouldBe("Plant1"); t.Type.ShouldBe(SparkplugMessageType.DDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA"); t.DeviceId.ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.UInt8, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug topic parse + datatype map"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 18 (P2): `BirthCache` + `AliasTable` — bind-by-name, rebuild-per-birth
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 19
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs` (per `(edgeNode,device)` alias→(name,datatype); **rebuilt wholesale each birth, never merged**)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs` (NBIRTH/DBIRTH metric catalog: name/alias/datatype/last value)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs`
|
||||
|
||||
**Why high-risk:** §3.6 invariants #1–#2 — binding data by alias silently mis-routes when an alias is reused for a different metric across a rebirth. Bind by **stable metric NAME**; the alias is a per-birth cache only.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — the two load-bearing invariants:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
|
||||
{
|
||||
var t = new AliasTable();
|
||||
t.RebuildFromBirth(new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float) });
|
||||
t.Resolve(alias: 5).Name.ShouldBe("Temperature");
|
||||
// rebirth: alias 5 now means a DIFFERENT metric
|
||||
t.RebuildFromBirth(new[] { (name:"Pressure", alias:5ul, dt:SparkplugDataType.Float) });
|
||||
t.Resolve(alias: 5).Name.ShouldBe("Pressure"); // wholesale replace, not merge
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildFromBirth_DoesNotMergeStaleAliases()
|
||||
{
|
||||
var t = new AliasTable();
|
||||
t.RebuildFromBirth(new[] { (name:"A", alias:1ul, dt:SparkplugDataType.Int32) });
|
||||
t.RebuildFromBirth(new[] { (name:"B", alias:2ul, dt:SparkplugDataType.Int32) });
|
||||
t.TryResolve(alias: 1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement — `RebuildFromBirth` replaces the whole map. `BirthCache` keeps the metric catalog + last value keyed by name.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 19 (P2): `SequenceTracker` — seq-gap + bdSeq death-tie
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 18
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs` (per edge-node `seq` 0–255 wrap gap detection; `bdSeq` ties NDEATH↔NBIRTH)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs`
|
||||
|
||||
**Why high-risk:** §3.6 invariant #3 — a wrong wrap boundary (255→0 is NOT a gap) either misses real gaps (stale data) or false-positives every wrap (rebirth-storm).
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — contiguous ok, wrap ok, gap detected:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Sequence_WrapAt255IsContiguous_NotAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sequence_SkippedValue_IsGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement (next-expected = `(last+1) & 0xFF`); `bdSeq` compare helper.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 20 (P2): `RebirthRequester` — NCMD encode
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/RebirthRequester.cs` (encode an NCMD writing `Node Control/Rebirth = true` to `spBv1.0/{group}/NCMD/{node}`; publish under a bounded deadline)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — encoded NCMD carries the `Node Control/Rebirth`=true metric and targets the right topic:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
|
||||
{
|
||||
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement (encode path via the generated proto).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): RebirthRequester NCMD encode"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 21 (P2): Sparkplug ingest state machine — the §3.6 matrix
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (integrates Tasks 16–20 into the driver)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs` (routes decoded messages: N/DBIRTH → rebuild `AliasTable` + `BirthCache`; N/DDATA → resolve alias→name → map to authored `FullReference` **by (group,node,device,metricName)** → `OnDataChange`; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth → `RebirthRequester` gated on `requestRebirthOnGap`; STATE/primary-host handling; late-join rebirth on connect)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (Sparkplug-mode subscribe `spBv1.0/{group}/#` (+ STATE) → `SparkplugIngestor`; reconnect → re-subscribe + request rebirth)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs`
|
||||
|
||||
**Why high-risk:** this is the §3.6 correctness core — the #1 risk in the design. Test the full matrix: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Birth_Then_Data_ResolvesByAlias_RaisesOnDataChangeByName()
|
||||
{
|
||||
var ing = new SparkplugIngestor(...); // authored tag: Plant1/EdgeA/Filler1:Temperature
|
||||
string? firedRef = null; ing.OnDataChange += (_, e) => firedRef = e.FullReference;
|
||||
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
|
||||
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:1, new[] { (alias:5ul, val:(object)21.5f) });
|
||||
firedRef.ShouldContain("Filler1:Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataBeforeBirth_RequestsRebirth()
|
||||
{
|
||||
var ncmds = new List<string>();
|
||||
var ing = new SparkplugIngestor(..., publishNcmd: (t,_) => ncmds.Add(t));
|
||||
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:0, new[] { (alias:9ul, val:(object)1f) });
|
||||
ncmds.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ddeath_EmitsStaleForDeviceMetrics_NextBirthRestoresGood()
|
||||
{
|
||||
var ing = new SparkplugIngestor(...);
|
||||
var quals = new List<StatusCode>(); ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
|
||||
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
|
||||
ing.HandleDdeath("Plant1","EdgeA","Filler1");
|
||||
quals.ShouldContain(q => StatusCode.IsBad(q)); // STALE/Bad on death
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the ingestor holding the §3.6 invariants. Route via `EquipmentTagRefResolver` keyed by `(group,node,device,metricName)`.
|
||||
4. Run — expect PASS (all matrix cases).
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 22 (P2): `ITagDiscovery` `UntilStable` + `IRediscoverable`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`RediscoverPolicy => UntilStable` in Sparkplug mode — authored tags' datatypes fill in as births arrive; `DiscoverAsync` re-streams authored tags with resolved datatypes from `BirthCache`; fire `OnRediscoveryNeeded` on a **new DBIRTH** or a rebirth with a changed metric set, `ScopeHint` = edge-node/device folder path)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — Sparkplug policy is `UntilStable`; a new DBIRTH fires `OnRediscoveryNeeded`; a tag authored before its birth picks up the birth datatype:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
|
||||
=> new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null)
|
||||
.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
|
||||
|
||||
[Fact]
|
||||
public async Task NewDbirth_FiresOnRediscoveryNeeded()
|
||||
{
|
||||
var d = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null);
|
||||
var fired = false; ((IRediscoverable)d).OnRediscoveryNeeded += (_, _) => fired = true;
|
||||
d.SimulateNewDbirthForTest("Plant1","EdgeA","Filler2");
|
||||
fired.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 23 (P2): Sparkplug browser tree + `AttributesAsync` + `RequestRebirthAsync`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 24
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (Sparkplug tree Group→EdgeNode→Device→Metric from observed births; `AttributesAsync` = self-describing metric `AttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass}`; `RequestRebirthAsync(scope)` — the **only** publishing session member, scoped to selected group/edge-node, via the `RebirthRequester` path)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (Sparkplug discovery filter `spBv1.0/{groupId}/#`; `OpenAsync` still publishes nothing)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` (dispatch the MQTT-specific `RequestRebirthAsync` for MQTT sessions, gated by the same `DriverOperator` policy that gates the picker; Info-log the target scope)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — birth-driven tree fills; `OpenAsync`/`RootAsync`/`ExpandAsync` publish **nothing**; `RequestRebirthAsync` publishes exactly one scoped NCMD:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","Temperature", SparkplugDataType.Float);
|
||||
var groups = await s.RootAsync(default);
|
||||
groups.ShouldContain(n => n.BrowseName == "Plant1");
|
||||
s.PublishCountForTest.ShouldBe(0); // passive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","T", SparkplugDataType.Float);
|
||||
await s.RequestRebirthAsync(scope: "Plant1/EdgeA");
|
||||
s.PublishCountForTest.ShouldBe(1);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement. `RequestRebirthAsync` is never fired by `OpenAsync`/`RootAsync`/`ExpandAsync` — only on explicit operator click.
|
||||
4. Run — expect PASS + `dotnet build` the AdminUI.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 24 (P2): AdminUI editor Sparkplug mode shape + validator
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 23
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (Sparkplug `Validate`: `groupId`/`edgeNodeId`/`metricName` required, `deviceId` optional, `dataType` a known Sparkplug type; `ToJson` re-derives `FullName` = `{group}/{node}[/{device}]:{metric}`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (Sparkplug field group under the mode switch)
|
||||
- Modify: `tests/Server/.../MqttTagConfigModelTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — Sparkplug validation + FullName derivation:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""").Validate().ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_DerivesFullName()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float"}""")
|
||||
.ToJson().ShouldContain("Plant1/EdgeA/Filler1:Temperature");
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS + AdminUI build.
|
||||
5. Commit: `git commit -am "feat(mqtt): typed tag editor Sparkplug mode + validation"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 25 (P2): C# edge-node simulator fixture + §3.6 live matrix
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/` (a project-owned C# edge-node simulator using the **same** MQTTnet-5 + Tahu-proto path the driver uses — publishes NBIRTH/DBIRTH → periodic N/DDATA; honours a rebirth NCMD; injects seq-gap / alias-reuse / N/DDEATH on demand)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (add the simulator container against the same TLS+auth Mosquitto)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugLiveTests.cs` (category `LiveIntegration`, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts **no NCMD published by open/browse**; explicit `RequestRebirthAsync` node-vs-group enumeration)
|
||||
|
||||
### Steps
|
||||
1. Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
|
||||
2. Write env-gated live tests covering the §3.6 matrix + browser passivity.
|
||||
3. Offline skip proof:
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
|
||||
```
|
||||
4. Deploy + run live:
|
||||
```bash
|
||||
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt sparkplug
|
||||
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
|
||||
```
|
||||
5. Commit: `git commit -am "test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 26 (P2): Live `/run` verify Sparkplug — **P2 COMPLETE**
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT/Sparkplug P1+P2 code-complete + live-verified)
|
||||
- Modify: `CLAUDE.md` (if a driver-fleet or endpoint fact changed; propagate to `../scadaproj/CLAUDE.md` OtOpcUa entry per the cross-repo rule)
|
||||
|
||||
### Steps
|
||||
1. On docker-dev (`:9200`): author an `Mqtt` driver (SparkplugB mode) against the simulator; browse the birth-driven picker, click **Request rebirth**, pick a metric, deploy.
|
||||
2. Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
|
||||
3. Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
|
||||
4. Update tracking + (if needed) CLAUDE.md/scadaproj index.
|
||||
5. Commit: `git commit -am "docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Deferred / out of scope
|
||||
|
||||
- **Write-through (P3 in the design):** `IWritable` — Sparkplug NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo (mirrors Galaxy fire-and-forget), `WriteIdempotent` default-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row). `MqttDriver` ships **read/subscribe/discover only** in P1+P2 — its absence of `IWritable` means nodes materialize read-only.
|
||||
- **`DataSet`/`Template` Sparkplug metrics** — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.
|
||||
- **`Bytes`/`File` metrics** beyond a base64/raw-String fallback. §3.5.
|
||||
- **EMQX broker** — Mosquitto is the default fixture; EMQX is the documented alternative when a dashboard/built-in Sparkplug tooling helps (§9), not built here.
|
||||
|
||||
## Notes on ordering / parallelism
|
||||
|
||||
- **P2 is blocked on the P1 milestone (Task 14).** Every P2 task's `blockedBy` chain roots at Task 14 so plain MQTT ships and is live-verified before Sparkplug work begins — and MQTTnet-5/net10 is proven (Task 0) before any proto/Sparkplug effort.
|
||||
- Genuine parallel pairs: 5‖6, 8‖10, 11‖12, 16‖17, 18‖19, 23‖24.
|
||||
- DRY: reuse `EquipmentTagRefResolver<MqttTagDefinition>` (as Modbus/OpcUaClient do), the shared `JsonSerializerOptions`, and one `SparkplugCodec` for both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
|
||||
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
|
||||
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
|
||||
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
|
||||
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
|
||||
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
|
||||
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
|
||||
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
|
||||
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
|
||||
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
|
||||
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
|
||||
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
|
||||
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
|
||||
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
|
||||
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
# MTConnect Agent Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ship a read-only, browsable `DriverType = "MTConnect"` Equipment-kind driver (P1 Agent MVP) that surfaces an MTConnect Agent's `/probe` device model as OPC UA nodes and serves live values via `/current` (read) + `/sample` long-poll (subscribe), with browse coming free from the Wave-0 universal discovery browser.
|
||||
|
||||
**Architecture:** One `MTConnectDriver` per instance implements `IDriver`, `ITagDiscovery` (`SupportsOnlineDiscovery=true`, `RediscoverPolicy=Once`), `IReadable`, `ISubscribable`, `IHostConnectivityProbe`, `IRediscoverable` — **not** `IWritable` (Agent surface is read-only). A thin `IMTConnectAgentClient` seam wraps the HTTP/XML transport (probe/current/sample) so the whole driver is unit-tested against canned XML with no network. A per-instance `MTConnectObservationIndex` holds `dataItemId → latest DataValueSnapshot`, updated by `/current` and the `/sample` pump; `UNAVAILABLE → BadNoCommunication`. Browse is served by the already-shipped `DiscoveryDriverBrowser` — this driver ships **no browser code**, only the `SupportsOnlineDiscovery` opt-in.
|
||||
|
||||
**Tech Stack:** `HttpClient` behind the `IMTConnectAgentClient` seam — **TrakHound MTConnect.NET (`-Common` + `-HTTP`, MIT, netstandard2.0) pending the Task 0 verification, else a hand-rolled `System.Xml.Linq` + multipart boundary-reader fallback (drop-in behind the same seam)**; `System.Text.Json` for config DTOs (enum-as-name, `JsonStringEnumConverter` on the probe, `ParseEnum<T>` on the factory); the Wave-0 universal browser seam (`ITagDiscovery.SupportsOnlineDiscovery`).
|
||||
|
||||
**Source design:** docs/plans/2026-07-15-mtconnect-driver-design.md
|
||||
**Program context:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures)
|
||||
**Wave-0 dependency:** docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting constraints (apply to every task)
|
||||
|
||||
- **Per-op deadline (R2-01 frozen-peer lesson).** Every agent HTTP call carries a bounded deadline: `/probe` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits.
|
||||
- **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy.
|
||||
- **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this.
|
||||
- **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`).
|
||||
- **Secrets from env/secret-refs.** No agent URL creds committed or logged.
|
||||
- **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (gates the `.csproj` package refs in Task 1)
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task)
|
||||
|
||||
The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency:
|
||||
|
||||
1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**.
|
||||
2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v <pin>` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10).
|
||||
3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved").
|
||||
|
||||
**Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `PackageReference`s). Any fail ⇒ **hand-roll fallback**: `HttpClient` + `System.Xml.Linq` for probe/current + a `multipart/x-mixed-replace` boundary reader for sample (~300–500 LoC behind the same `IMTConnectAgentClient` seam; the driver above the seam is byte-identical either way). Record the chosen path and the observed version/TFM/license as a one-paragraph **DECISION** note appended to this task in the plan file.
|
||||
|
||||
**No test / no commit** for this task (it's a decision + a doc edit). Commit the plan edit with the next task, or standalone:
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-07-24-mtconnect-driver.md
|
||||
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold the two driver projects + the test project
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (every later task builds on these projects)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the three `<Project Path=...>` lines)
|
||||
|
||||
Steps:
|
||||
- Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet).
|
||||
- The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus).
|
||||
- The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…`).
|
||||
- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~29–92 pattern).
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj # expect: PASS (empty compile)
|
||||
```
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "build(mtconnect): scaffold Contracts+Driver+Tests projects, add to slnx (Task 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `MTConnectDriverOptions` + `MTConnectTagDefinition` in `.Contracts`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3, Task 4
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`
|
||||
|
||||
Mirror `ModbusDriverOptions`: strongly-typed record/POCO holding `AgentUri` (required), optional `DeviceName` scope, `RequestTimeoutMs`, `SampleIntervalMs`, `SampleCount`, `HeartbeatMs`, a `Probe` sub-record (mirror `ModbusProbeOptions`: `Enabled`/`Interval`/`Timeout`), and a `Reconnect` sub-record (`MinBackoffMs`/`MaxBackoffMs`/multiplier — mirror `ModbusReconnectOptions`). `MTConnectTagDefinition`: one record per authored tag — `FullName` (= `dataItemId`), `DriverDataType`, `IsArray`, `ArrayDim`, plus `mtCategory`/`mtType`/`mtSubType`/`units` metadata.
|
||||
|
||||
TDD — write the failing test first (defaults + required-field shape):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Options_default_sample_and_heartbeat_are_sane()
|
||||
{
|
||||
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
|
||||
o.SampleIntervalMs.ShouldBeGreaterThan(0);
|
||||
o.HeartbeatMs.ShouldBeGreaterThan(0);
|
||||
o.Probe.Enabled.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverOptionsTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): driver options + tag definition records (Task 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `MTConnectDataTypeInference.Infer` + golden type-map test
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 4
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs`
|
||||
|
||||
Pure `static DriverDataType Infer(string category, string? type, string? units, string? representation)` implementing the §3.3 table. It lives in `.Contracts` so the driver, browser-commit, and the typed editor all agree. Return `(DriverDataType, bool isArray, uint? arrayDim)` (or a small record) so `TIME_SERIES` can carry `IsArray=true` + declared `sampleCount`.
|
||||
|
||||
Golden table (design §3.3):
|
||||
|
||||
| category / shape | result |
|
||||
|---|---|
|
||||
| `SAMPLE` numeric with `units` | `Float64` |
|
||||
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`, `ArrayDim=sampleCount` or null) |
|
||||
| `EVENT` numeric type (`PartCount`, `Line`) | `Int64` |
|
||||
| `EVENT` controlled-vocab (`Execution`, `ControllerMode`, `Availability`) | `String` |
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` |
|
||||
|
||||
TDD — one `[Theory]` covering every row (golden):
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
|
||||
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
|
||||
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
|
||||
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
|
||||
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
|
||||
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
|
||||
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
|
||||
{
|
||||
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
|
||||
r.DataType.ShouldBe(DriverDataType.Float64);
|
||||
r.IsArray.ShouldBeTrue();
|
||||
r.ArrayDim.ShouldBe(8u);
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDataTypeInferenceTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): pure data-type inference table + golden test (Task 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Capture the canned XML fixtures
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml`
|
||||
|
||||
These canned docs drive the bulk of unit coverage with no network (design §8.1). Capture from a demo agent (`https://demo.mtconnect.org/probe` / `/current` / `/sample`) or hand-author minimal valid MTConnect 1.x XML covering:
|
||||
- `probe.xml` — one `MTConnectDevices` with a `Device` → nested `Component` → `DataItem`s spanning every §3.3 category (a `SAMPLE` w/ units, a `TIME_SERIES` w/ `sampleCount`, an `EVENT` numeric, an `EVENT` controlled-vocab, a `CONDITION`), each with a distinct `id`.
|
||||
- `current.xml` — an `MTConnectStreams` with a `Header instanceId=... nextSequence=...` and one observation per data item, including at least one `<... >UNAVAILABLE</...>`.
|
||||
- `sample.xml` — an `MTConnectStreams` chunk with several observations and a `Header nextSequence` that continues contiguously from `current.xml`.
|
||||
- `sample-gap.xml` — a `Header` whose `firstSequence` is **newer** than the `from` the driver would request (the ring-buffer-overflow / forced-`nextSequence`-gap case Task 7 + Task 11 assert re-baseline on).
|
||||
- `current-unavailable.xml` — every observation `UNAVAILABLE` (Task 8's quality-mapping fixture).
|
||||
|
||||
No code, no test yet — verify the files copy to bin:
|
||||
|
||||
```bash
|
||||
dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests # PASS; then confirm bin/.../Fixtures/*.xml exist
|
||||
git add -A && git commit -m "test(mtconnect): canned probe/current/sample XML fixtures incl. forced-gap (Task 4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `IMTConnectAgentClient` seam + return DTOs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 4
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs` (parsed model + observation records)
|
||||
|
||||
Define the seam the whole driver is tested behind:
|
||||
|
||||
```csharp
|
||||
public interface IMTConnectAgentClient
|
||||
{
|
||||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||||
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
`MTConnectProbeModel` — devices → components (nested) → data items (`Id`, `Name?`, `Category`, `Type`, `SubType?`, `Units?`, `Representation?`, `SampleCount?`). `MTConnectStreamsResult` — `long InstanceId`, `long NextSequence`, `long FirstSequence`, and `IReadOnlyList<MTConnectObservation>` (`DataItemId`, `Value` (string or `"UNAVAILABLE"`), `TimestampUtc`). These DTOs are the neutral shape both the TrakHound adapter and the hand-roll fallback produce, so the driver never sees TrakHound types (or `XElement`) directly.
|
||||
|
||||
Verify (compiles only):
|
||||
|
||||
```bash
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect # PASS
|
||||
git add -A && git commit -m "feat(mtconnect): IMTConnectAgentClient seam + neutral parse DTOs (Task 5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `MTConnectAgentClient` — parse `/probe` into the device model
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (probe leg only this task)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs`
|
||||
|
||||
Implement `ProbeAsync` — either via TrakHound's `MTConnectHttpClient` (map its `IDevice`/`IComponent`/`IDataItem` into the neutral `MTConnectProbeModel`) or the hand-roll `System.Xml.Linq` walk of `probe.xml`. The `/probe` call carries the linked-CTS deadline. To keep the parse itself unit-testable without a socket, factor the byte→model parse into an internal static (`MTConnectProbeParser.Parse(Stream|string)`) and feed it the fixture directly.
|
||||
|
||||
TDD:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Probe_parse_yields_nested_components_and_all_dataitems()
|
||||
{
|
||||
var xml = await File.ReadAllTextAsync("Fixtures/probe.xml");
|
||||
var model = MTConnectProbeParser.Parse(xml);
|
||||
model.Devices.ShouldHaveSingleItem();
|
||||
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
|
||||
ids.ShouldContain("<the CONDITION dataItem id from the fixture>");
|
||||
model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectProbeParseTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): agent client /probe parse to device model (Task 6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `MTConnectAgentClient` — parse `/current` + `/sample`, detect the sequence gap
|
||||
|
||||
**Classification:** high-risk (ring-buffer / sequence paging correctness)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (add current/sample legs + `MTConnectStreamsParser`)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs`
|
||||
|
||||
`CurrentAsync` parses `current.xml` → `MTConnectStreamsResult` (Header `instanceId`/`nextSequence`/`firstSequence` + observations). `SampleAsync(from, ct)` yields one `MTConnectStreamsResult` per multipart chunk and **exposes the gap signal**: expose a helper `bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk) => chunk.FirstSequence > requestedFrom` so Task 11's pump can re-baseline. Advance the caller's `from` to `chunk.NextSequence` (contiguous). Chunk framing (the multipart boundary reader) is TrakHound-internal or the hand-roll's boundary reader — either way the parser is fed one chunk's XML.
|
||||
|
||||
TDD (the forced-gap fixture is the load-bearing case):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Current_parse_reads_header_sequences_and_observations()
|
||||
{
|
||||
var r = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml"));
|
||||
r.NextSequence.ShouldBeGreaterThan(0);
|
||||
r.Observations.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
|
||||
{
|
||||
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/sample-gap.xml"));
|
||||
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectStreamsParseTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs`
|
||||
|
||||
Thread-safe `dataItemId → DataValueSnapshot` map updated by `/current` and `/sample`. The snapshot builder maps a raw observation → `DataValueSnapshot`:
|
||||
- value `UNAVAILABLE` → `new(null, BadNoCommunication, ts, now)` where `private const uint BadNoCommunication = 0x80310000u;` (design §3.3 — declared as a const, the Modbus `StatusBadCommunicationError` pattern; renders by name in the CLI's `SnapshotFormatter`).
|
||||
- a present value → coerced to the tag's `DriverDataType` with `StatusCode = Good (0)`, `SourceTimestampUtc = observation.timestamp`.
|
||||
- a `dataItemId` never seen / empty condition → `Bad`-coded snapshot.
|
||||
|
||||
TDD (drive off `current.xml` + `current-unavailable.xml`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current-unavailable.xml")));
|
||||
var snap = idx.Get("<a dataItemId from the fixture>");
|
||||
snap.Value.ShouldBeNull();
|
||||
snap.StatusCode.ShouldBe(0x80310000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Present_value_indexes_good_with_source_timestamp()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml")));
|
||||
var snap = idx.Get("<a good dataItemId>");
|
||||
snap.StatusCode.ShouldBe(0u);
|
||||
snap.SourceTimestampUtc.ShouldNotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectObservationIndexTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: `MTConnectDriver` shell — `IDriver` lifecycle
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (IDriver members only this task)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs`
|
||||
|
||||
Ctor `MTConnectDriver(MTConnectDriverOptions options, string driverInstanceId, Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null, ILogger<MTConnectDriver>? logger = null)` — **connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement:
|
||||
- `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure.
|
||||
- `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed).
|
||||
- `ShutdownAsync`: stop stream, dispose client.
|
||||
- `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)` — `LastSuccessfulRead` = last `/current`|`/sample` chunk time.
|
||||
- `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index.
|
||||
|
||||
TDD with a canned-XML fake client:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Initialize_primes_index_and_reports_healthy()
|
||||
{
|
||||
var fake = CannedAgentClient.FromFixtures(); // test helper: serves probe.xml + current.xml
|
||||
var d = new MTConnectDriver(Opts(), "mt1", _ => fake);
|
||||
await d.InitializeAsync("{\"agentUri\":\"http://x\"}", default);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverLifecycleTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: `IReadable.ReadAsync` — `/current`, ordered snapshots
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 12, Task 13
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs`
|
||||
|
||||
`ReadAsync(fullReferences, ct)`: one `/current` under the per-call deadline, index the whole-device response, return **one `DataValueSnapshot` per requested ref in order**; a ref absent from the response → a `Bad`-coded snapshot (never a throw). Batch reads cost one round-trip.
|
||||
|
||||
TDD:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
|
||||
{
|
||||
var d = await InitializedDriver();
|
||||
var res = await d.ReadAsync(new[] { "<good id>", "does-not-exist" }, default);
|
||||
res.Count.ShouldBe(2);
|
||||
res[0].StatusCode.ShouldBe(0u);
|
||||
(res[1].StatusCode & 0x80000000u).ShouldBe(0x80000000u); // Bad
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectReadTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: `ISubscribable` — `/sample` long-poll pump + ring-buffer re-baseline
|
||||
|
||||
**Classification:** high-risk (streaming state + re-baseline correctness)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs` (`ISubscriptionHandle`)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs`
|
||||
|
||||
- `SubscribeAsync`: on the **first** subscription, start the shared sample stream from `nextSequence`; record the subscribed ref set; immediately fire `OnDataChange` for each subscribed ref from the primed `/current` (initial-data convention); return a handle (monotonic id + `DiagnosticId`). The stream-start handshake must complete inside the invoker's Subscribe timeout; the long-lived pump then runs under the heartbeat watchdog, not that timeout.
|
||||
- Pump: each chunk → for each observation whose `dataItemId ∈` subscribed set, update the index + raise `OnDataChange(handle, dataItemId, snapshot)`; advance `from = nextSequence`.
|
||||
- **Ring-buffer overflow:** when `IsSequenceGap(from, chunk)` (Task 7) → re-`/current` to re-baseline, then resume from the new `nextSequence`.
|
||||
- `UnsubscribeAsync`: drop the handle's refs; stop the shared stream when the set empties.
|
||||
|
||||
TDD — assert the initial-data callback AND that a forced gap triggers a re-baseline `/current` (drive `SampleAsync` to yield `sample-gap.xml`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_initial_data_from_current()
|
||||
{
|
||||
var d = await InitializedDriver();
|
||||
var seen = new List<string>();
|
||||
d.OnDataChange += (_, e) => seen.Add(e.FullReference);
|
||||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(500), default);
|
||||
seen.ShouldContain("<good id>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sequence_gap_triggers_recurrent_current_rebaseline()
|
||||
{
|
||||
var fake = CannedAgentClient.WithGapThenResume(); // sample-gap.xml then a contiguous chunk
|
||||
var d = await InitializedDriver(fake);
|
||||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
|
||||
await fake.PumpOnce();
|
||||
fake.CurrentCallCount.ShouldBeGreaterThan(1); // initial prime + re-baseline
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectSubscribeTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: `ITagDiscovery.DiscoverAsync` + `SupportsOnlineDiscovery=true`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 13
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs`
|
||||
|
||||
`public bool SupportsOnlineDiscovery => true;` + `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;` (probe is synchronous + complete — this one member override is the whole browse opt-in; the Wave-0 universal browser does the rest). `DiscoverAsync(builder, ct)` streams the cached/`/probe` model into the builder:
|
||||
- each `Device` → `builder.Folder(name, name)` → child builder;
|
||||
- each nested `Component` → recursive `Folder(name, displayName)` on the parent's child builder;
|
||||
- each `DataItem` → `child.Variable(browseName, displayName, attr)` with `browseName = dataItem.Name ?? dataItem.Id`, and
|
||||
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: <Infer(...).DataType>, IsArray: rep==TIME_SERIES, ArrayDim: <declared sampleCount or null>, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`.
|
||||
- If `DeviceName` is set, stream only that device's subtree.
|
||||
|
||||
**Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction.
|
||||
|
||||
TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
|
||||
{
|
||||
var d = await InitializedDriver();
|
||||
var cap = new CapturingBuilder();
|
||||
await d.DiscoverAsync(cap, default);
|
||||
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("<CONDITION dataItem id>");
|
||||
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
d.SupportsOnlineDiscovery.ShouldBeTrue();
|
||||
d.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDiscoverTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: `IHostConnectivityProbe` + `IRediscoverable` (instanceId watch)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 12
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs`
|
||||
|
||||
- `IHostConnectivityProbe`: one `HostConnectivityStatus` per Agent (`HostName = AgentUri`); a cheap periodic `/probe` (or the sample-stream heartbeat) flips `Running ↔ Stopped`; raise `OnHostStatusChanged` on transition. Enable/interval from `MTConnectDriverOptions.Probe`.
|
||||
- `IRediscoverable`: cache `Header.instanceId` from Initialize; on every `/current`/`/sample` chunk, a **changed** `instanceId` means the Agent restarted / its model changed → raise `OnRediscoveryNeeded(new("MTConnect agent instanceId changed", null))`. This is why `RediscoverPolicy = Once` is safe — instanceId change, not polling, drives re-discovery.
|
||||
|
||||
TDD:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task InstanceId_change_raises_rediscovery()
|
||||
{
|
||||
var fake = CannedAgentClient.WithInstanceIdChangeOnNextChunk();
|
||||
var d = await InitializedDriver(fake);
|
||||
RediscoveryEventArgs? got = null;
|
||||
((IRediscoverable)d).OnRediscoveryNeeded += (_, e) => got = e;
|
||||
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
|
||||
await fake.PumpOnce();
|
||||
got.ShouldNotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectHostAndRediscoverTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 14: `MTConnectDriverProbe : IDriverProbe`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 15
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs`
|
||||
|
||||
Mirror `ModbusDriverProbe`: `DriverType => "MTConnect"`; parse the config DTO with a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` (copy `ModbusDriverProbe._opts` — the enum-serialization gotcha); one-shot `GET {AgentUri}/probe` under `timeout` (linked-CTS); `Ok=true`+latency on any valid `MTConnectDevices` response; `Ok=false`+message on TCP/HTTP/timeout failure. **Never throws** (`IDriverProbe` contract).
|
||||
|
||||
TDD (unit — parse + never-throw; live reachability is Task 20's integration job):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
|
||||
{
|
||||
var probe = new MTConnectDriverProbe();
|
||||
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_on_blank_config_returns_not_ok()
|
||||
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverProbeTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 15: `MTConnectDriverFactoryExtensions`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 14
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
|
||||
|
||||
Copy the Modbus factory: `public const string DriverTypeName = "MTConnect";` `Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)` → `registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory))`. `CreateInstance` deserializes an `MTConnectDriverConfigDto` (nullable-init DTO with `AgentUri`, `DeviceName`, timeouts, `Tags`, `Probe`), validates `AgentUri` present, builds `MTConnectDriverOptions`, returns `new MTConnectDriver(options, id, agentClientFactory: null, logger: loggerFactory?.CreateLogger<MTConnectDriver>())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum<T>`).
|
||||
|
||||
TDD:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Factory_builds_a_driver_from_minimal_config()
|
||||
{
|
||||
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||
d.DriverType.ShouldBe("MTConnect");
|
||||
d.DriverInstanceId.ShouldBe("mt1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_rejects_config_without_agentUri()
|
||||
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectFactoryTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): driver factory + config DTO (Task 15)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Host registration + `DriverTypeNames.MTConnect` + guard-test parity
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `MTConnect` const + append to `All`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (Register call + probe alias + `TryAddEnumerable`)
|
||||
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj` (add ProjectReference to `Driver.MTConnect`)
|
||||
|
||||
Three coupled edits that **must land together** to keep `DriverTypeNamesGuardTests` green (it asserts exact-set parity between `DriverTypeNames.All` and the reflection-discovered registered factories, scanning `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in the test's bin):
|
||||
1. Add `public const string MTConnect = "MTConnect";` to `DriverTypeNames` and append it to `All` (design uses `DriverTypeNames.MTConnect` in the editor map/validator per the CLAUDE.md "keyed off constants" rule).
|
||||
2. In `DriverFactoryBootstrap`: add `using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;`, `Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)`, and `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw).
|
||||
3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.**
|
||||
|
||||
Verify (the guard test is the gate here):
|
||||
|
||||
```bash
|
||||
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~DriverTypeNamesGuardTests" # RED before the ProjectReference/Register land together, GREEN after
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host # PASS
|
||||
git add -A && git commit -m "feat(mtconnect): host registration + DriverTypeNames const + guard parity (Task 16)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 17: AdminUI typed model `MTConnectTagConfigModel`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs`
|
||||
- Test: `tests/Server/<AdminUI test project>/MTConnectTagConfigModelTests.cs` (mirror the Modbus model test's location)
|
||||
|
||||
Copy `ModbusTagConfigModel`: pure `FromJson`/`ToJson`/`Validate`, **preserves unknown keys** via the `TagConfigJson` bag. Fields: `FullName` (dataItemId, required), `MtCategory`/`MtType`/`MtSubType` (from probe — read-only in UI), `DataType` (`DriverDataType` override), `Units`, `MtDevice`/`MtComponent` (author context). `ToJson` writes enums via `TagConfigJson.Set` (**name strings** — the enum-serialization trap). `Validate()` returns an error when `FullName` is blank.
|
||||
|
||||
TDD:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
|
||||
{
|
||||
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
|
||||
var m = MTConnectTagConfigModel.FromJson(json);
|
||||
var outJson = m.ToJson();
|
||||
outJson.ShouldContain("\"somethingUnknown\":42");
|
||||
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_blocks_blank_fullname()
|
||||
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/Server/<AdminUI test project> --filter "FullyQualifiedName~MTConnectTagConfigModelTests" # RED, then GREEN
|
||||
git add -A && git commit -m "feat(mtconnect): typed AdminUI tag-config model (Task 17)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 18: AdminUI editor razor + map/validator registration
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs`
|
||||
|
||||
Thin razor shell over `MTConnectTagConfigModel` (mirror `ModbusTagConfigEditor.razor`): `FullName` text, read-only `mtCategory`/`mtType`, a `DataType` override `<select>`. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface. Register:
|
||||
- `[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)` in `TagConfigEditorMap`.
|
||||
- `[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()` in `TagConfigValidator`.
|
||||
|
||||
(No AdminUI `IDriverBrowser` DI line — browse is the universal browser, already registered.)
|
||||
|
||||
Verify (AdminUI has no bUnit — Razor binding is validated live in Task 21; here just compile):
|
||||
|
||||
```bash
|
||||
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI # PASS
|
||||
git add -A && git commit -m "feat(mtconnect): typed tag editor razor + map/validator entries (Task 18)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 19: `mtconnect/cppagent` docker fixture
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 17, Task 18
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg` (if the image needs it)
|
||||
|
||||
Compose one `mtconnect/cppagent` service, **`project: lmxopcua` label on the service** (host-side `lmxopcua-fix` convention), seeded with a canned `Devices.xml` (+ the image's built-in adapter/simulator so `/current` returns live-ish data), exposed on the shared docker host `10.100.0.35`. Deploy via `lmxopcua-fix sync mtconnect` + `lmxopcua-fix up mtconnect` (repo `Docker/` is source of truth; `/opt/otopcua-mtconnect/` is the mirror). Model the compose on the Modbus fixture's shape.
|
||||
|
||||
No .NET test in this task (fixture only). Verify the compose parses:
|
||||
|
||||
```bash
|
||||
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml config # PASS
|
||||
git add -A && git commit -m "test(mtconnect): cppagent docker fixture + seeded Devices.xml (Task 19)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 20: Env-gated integration suite against cppagent
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
A `*.IntegrationTests` suite that reads the agent endpoint from an env var (e.g. `MTCONNECT_AGENT_ENDPOINT`, default `http://10.100.0.35:5000`) and **skips cleanly when unset/down** (the Modbus/S7 pattern — `Skip.If(...)` or a fixture-reachability guard). Cover the real HTTP round-trips a canned fixture can't: `MTConnectDriverProbe` reachability green, `InitializeAsync` + `DiscoverAsync` build a non-empty tree, `ReadAsync` returns live values, `SubscribeAsync` delivers at least one `OnDataChange` from the live `/sample` stream.
|
||||
|
||||
Verify it skips offline (safe on macOS with no fixture up):
|
||||
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests # all tests SKIP when the env var/endpoint is absent
|
||||
git add -A && git commit -m "test(mtconnect): env-gated cppagent integration suite (Task 20)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 21: Live `/run` verify on docker-dev — browse picker, editor, read, subscribe, deploy
|
||||
|
||||
**Classification:** high-risk (live; Razor + deploy-inertness bugs pass unit tests)
|
||||
**Estimated implement time:** ~5 min (driving; excludes fixture spin-up)
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (append a LIVE-GATE RESULT note)
|
||||
|
||||
> **DEPENDENCY:** this task's browse-picker leg is gated on the **Wave-0 universal-browser live gate (Gitea #468)** being closed — the picker's Browse button, `DiscoveryDriverBrowser.CanBrowse`, and `CapturedTreeBrowseSession` are Wave-0 machinery this driver only opts into via `SupportsOnlineDiscovery=true`. If #468 is still open, run the read/subscribe/deploy legs and record the browse leg as blocked-on-#468.
|
||||
|
||||
Bring the cppagent fixture up (`lmxopcua-fix up mtconnect`), author an MTConnect driver on docker-dev (`http://localhost:9200`, auto-authenticated admin — no login), and verify **in the running AdminUI + against `opc.tcp://localhost:4840`** (per the live-verify discipline — Razor binding bugs pass unit tests):
|
||||
1. **Test Connect** on the MTConnect driver goes green (probe reaches the fixture).
|
||||
2. **Browse picker** (`/uns` TagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commits `TagConfig.FullName = <dataItemId>` (Wave-0 dependency).
|
||||
3. **Typed editor** shows `FullName`/read-only `mtCategory`/`DataType` override and round-trips.
|
||||
4. **Deploy** the config; via Client.CLI read a picked node (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=…;s=…"`) — value present, `UNAVAILABLE` items render `BadNoCommunication`.
|
||||
5. **Subscribe** (`... subscribe ...`) — live updates flow from the `/sample` stream.
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-07-24-mtconnect-driver.md
|
||||
git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/subscribe/deploy (Task 21)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 22: Docs + deferred-writeback note; update the tracking doc
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `docs/drivers/MTConnect.md` (or a short section — mirror an existing driver doc)
|
||||
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (mark MTConnect P1 done + link this plan)
|
||||
|
||||
Document the P1 Agent MVP: config keys, browse-via-universal, the `UNAVAILABLE→BadNoCommunication` semantics, CONDITION-as-String, and the fixture recipe. Record **write-back (MTConnect Interfaces) as deferred** — point at `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6 + §9 (P1.5 native-alarm CONDITION, P2 SHDR ingest are also deferred there). Update the driver-expansion tracking doc's Wave-2 row.
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deferred (out of P1 — pointers, not scope)
|
||||
|
||||
- **Write-back (MTConnect Interfaces)** — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
|
||||
- **CONDITION → native Part-9 alarms** (`IAlarmSource`), **`TIME_SERIES` SAMPLE arrays** materialized as OPC UA arrays, **EVENT controlled-vocab → OPC UA enumerations** — design §9 P1.5 fast-follow.
|
||||
- **SHDR adapter ingest** (`SourceMode: "Agent" | "Shdr"`, loses auto-discovery) — design §9 P2.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
|
||||
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
|
||||
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
|
||||
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
|
||||
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
|
||||
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
|
||||
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
|
||||
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
|
||||
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
|
||||
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
# SQL Poll Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ship a read-only `Sql` Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
|
||||
|
||||
**Architecture:** Three new projects mirroring the Modbus split — `Driver.Sql.Contracts` (options/tag DTOs + parser, zero transport deps), `Driver.Sql` (the `IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable`/`IHostConnectivityProbe` runtime + `ISqlDialect` seam + `SqlServerDialect`, owning `Microsoft.Data.SqlClient`), and `Driver.Sql.Browser` (schema-walk `IBrowseSession`). The `PollGroupEngine`, `EquipmentTagRefResolver<TDef>`, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a `DbParameter`; identifiers are dialect-quoted from catalog-validated names only.
|
||||
|
||||
**Tech Stack:** `Microsoft.Data.SqlClient` (6.1.1, already in `Directory.Packages.props`) via `System.Data.Common` base types behind a `DbProviderFactory`; `ISqlDialect` + `SqlProvider` enum seam (only `SqlServer` implemented in v1); `Microsoft.Data.Sqlite` (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. **Zero new NuGet dependencies.**
|
||||
|
||||
**Source design:** docs/plans/2026-07-15-sql-poll-driver-design.md
|
||||
**Program design (shared contract):** docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
|
||||
|
||||
**Conventions for every task below:** all projects `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors` on product projects. Every commit message ends with the repo trailer `Claude-Session: <session-url>` (omitted from the `git commit` examples for brevity). Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` green before each commit. New test projects use `xunit.v3` + `Shouldly` (see `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj`).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Scaffold `Driver.Sql.Contracts` project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (root of the tree)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project under `/src/Drivers/`)
|
||||
|
||||
**Steps:**
|
||||
1. Create the csproj — refs `Core.Abstractions` ONLY (no transport deps, so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`). `InternalsVisibleTo` the Contracts is not needed. `RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts`.
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under the `/src/Drivers/` folder, beside the `Driver.Modbus.Contracts` line.
|
||||
3. Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS (empty project compiles).
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `SqlProvider` + `SqlTagModel` enums + `SqlDriverConfigDto` (string-enum round-trip)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocks most downstream)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*` is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. **Instead**, put this task's test in a throwaway `Contracts`-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. **This task ships DTOs only; its guard is Task 9.**
|
||||
|
||||
> **Note:** Tasks 1–2 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, **reorder locally**: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The `blockedBy` graph permits this. The concrete failing-test-first discipline begins at Task 2.
|
||||
|
||||
**Steps:**
|
||||
1. `SqlProvider.cs`:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
|
||||
|
||||
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
|
||||
public enum SqlTagModel { KeyValue, WideRow, Query }
|
||||
```
|
||||
2. `SqlDriverConfigDto.cs` — the driver-config blob shape (§5.1). Enum-typed fields (`Provider`) so the string converter round-trips names; timeouts as `TimeSpan?`; `ConnectionStringRef` (NOT the connection string). Fields: `Provider`, `ConnectionStringRef`, `DefaultPollInterval`, `OperationTimeout`, `CommandTimeout`, `MaxConcurrentGroups`, `NullIsBad`, `AllowWrites`, `Probe` (`SqlProbeDto{Enabled, Interval}`). `[JsonStringEnumConverter]` is applied at the factory (Task 9), not on the DTO.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SqlTagDefinition` + `SqlEquipmentTagParser.TryParse` (strict enum reads, malicious-input safe)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs` (Tests project scaffolded in Task 6 — **pull the csproj creation forward from Task 6 for this task**, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — parse a KeyValue blob and a malicious `keyValue`; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd `model` enum rejects:
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
public class SqlEquipmentTagParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
|
||||
{
|
||||
var json = """
|
||||
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
|
||||
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
|
||||
""";
|
||||
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
|
||||
def.Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
def.Table.ShouldBe("dbo.TagValues");
|
||||
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
|
||||
def.DeclaredType.ShouldBe(DriverDataType.Float64);
|
||||
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_invalidModelEnum_rejectsTag()
|
||||
=> SqlEquipmentTagParser.TryParse(
|
||||
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
|
||||
"raw", out _).ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests` — expect FAIL (types don't exist).
|
||||
3. **Minimal impl** — `SqlTagDefinition` record (`Name` = RawPath, `Model`, `Table`, `KeyColumn`, `KeyValue`, `ValueColumn`, `TimestampColumn`, `ColumnName`, `RowSelectorColumn`, `RowSelectorValue`, `RowSelectorTopByTimestamp`, `DeclaredType` = `DriverDataType?`). `SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)` — mirror `ModbusTagDefinitionFactory.FromTagConfig`: recognize by leading `{`, read the `model` discriminator via `TagConfigJson.TryReadEnumStrict` (a present-but-invalid enum → `false` → the driver surfaces `BadNodeIdUnknown`, per R2-11), read model-specific fields, read the optional `type` override strictly, set `Name = rawPath`. Wrap in try/catch(JsonException/FormatException) → `false`. **No SQL is built here** — the parser only captures strings.
|
||||
4. Run test — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Scaffold `Driver.Sql` runtime project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
**Steps:**
|
||||
1. Create the csproj per design §2.1 — refs `Core.Abstractions` + `Core` (for `DriverFactoryRegistry` in `ZB.MOM.WW.OtOpcUa.Core.Hosting`) + `Driver.Sql.Contracts`; packages `Microsoft.Data.SqlClient` + `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` the `.Tests`.
|
||||
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under `/src/Drivers/`.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql runtime project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ISqlDialect` seam + `SqlServerDialect` (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
|
||||
|
||||
**Classification:** high-risk (identifier quoting is the injection boundary)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — golden catalog SQL, `QuoteIdentifier` escape + reject, `MapColumnType` table:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
|
||||
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_rejectsControlCharsAndNul()
|
||||
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("bit", DriverDataType.Boolean)]
|
||||
[InlineData("int", DriverDataType.Int32)]
|
||||
[InlineData("bigint", DriverDataType.Int64)]
|
||||
[InlineData("real", DriverDataType.Float32)]
|
||||
[InlineData("float", DriverDataType.Float64)]
|
||||
[InlineData("decimal", DriverDataType.Float64)]
|
||||
[InlineData("nvarchar", DriverDataType.String)]
|
||||
[InlineData("datetime2", DriverDataType.DateTime)]
|
||||
[InlineData("uniqueidentifier", DriverDataType.String)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isInformationSchemaAndParameterized()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.LivenessSql.ShouldBe("SELECT 1");
|
||||
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
|
||||
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain("@table");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `ISqlDialect` per design §2.2 (`Provider`, `Factory` = `DbProviderFactory`, `QuoteIdentifier`, `LivenessSql`, `ListSchemasSql`, `ListTablesSql`, `ListColumnsSql`, `MapColumnType`). `SqlServerDialect`: `Factory => SqlClientFactory.Instance`; `QuoteIdentifier` doubles `]`, rejects embedded NUL/control chars via `ArgumentException`; catalog SQL uses `INFORMATION_SCHEMA` with `@schema`/`@table` markers; `MapColumnType` folds SQL type families to `DriverDataType` per design §3.7 (`decimal`/`numeric`/`money` → `Float64` with the documented precision caveat as an XML remark).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `SqlQueryPlan` grouping + parameter binding (pure, no DB)
|
||||
|
||||
**Classification:** high-risk (the query-mapping core; GroupKey correctness governs correctness of every read)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — KeyValue tags on the same `(table,keyColumn,valueColumn,timestampColumn)` fold into ONE plan whose SQL is `... WHERE [tag_name] IN (@k0,@k1)` with two bound params; WideRow tags on the same `(table,rowSelector)` fold into one `SELECT <cols> ... WHERE [station_id]=@w`; different tables → different plans:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
|
||||
{
|
||||
var dialect = new SqlServerDialect();
|
||||
var tags = new[] {
|
||||
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
|
||||
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
|
||||
};
|
||||
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
|
||||
plans[0].SqlText.ShouldContain("[tag_name]");
|
||||
plans[0].Members.Count.ShouldBe(2);
|
||||
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }` (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). `SqlGroupPlanner.Plan(tags, dialect)`: KeyValue → GroupKey `(table,keyColumn,valueColumn,timestampColumn)`, emit `SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w` (or `ORDER BY <col> DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `<table>` split on `.` and each part quoted. **Every value is a param; zero interpolation.**
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Scaffold `Driver.Sql.Tests` + `SqliteDialect` (test) + `SqlitePollFixture`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 12
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
> If Task 2 already created the csproj, this task adds the `SqliteDialect`/fixture and the slnx entry only.
|
||||
|
||||
**Steps:**
|
||||
1. csproj — `xunit.v3` + `Shouldly` + `Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`; `PackageReference Microsoft.Data.Sqlite` (10.0.7, test-only — no product dep on SQLite); project-refs `Driver.Sql` + `Driver.Sql.Contracts`. Add to slnx under `/tests/Drivers/`.
|
||||
2. `SqliteDialect : ISqlDialect` — `Provider = SqlServer` shim is wrong; add a test-only `Provider` value is not needed — implement `ISqlDialect` directly with `Factory => SqliteFactory.Instance`, `QuoteIdentifier` doubling `"`, `LivenessSql = "SELECT 1"`, catalog SQL over `sqlite_schema` + `PRAGMA table_info(...)`, `MapColumnType` mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via `<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />`.
|
||||
3. `SqlitePollFixture` — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table `TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)` and a wide-row `LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)` with a few rows. Exposes the open `SqliteConnection` + a `DbProviderFactory` shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
|
||||
4. Build + run (no tests yet) — expect PASS/empty.
|
||||
5. Commit: `git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `SqlPollReader` core — one query per group, bounded deadline, slice back in input order
|
||||
|
||||
**Classification:** high-risk (connection/timeout/result-slicing core)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** (against `SqlitePollFixture`) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → `BadNoData`, NULL cell → `Uncertain` (default `nullIsBad=false`), source timestamp from `timestampColumn`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
|
||||
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
|
||||
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
|
||||
var refs = new[] { "Speed", "Missing", "Temp" };
|
||||
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
|
||||
snaps.Count.ShouldBe(3);
|
||||
snaps[0].Value.ShouldBe(42.0);
|
||||
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
|
||||
snaps[2].Value.ShouldBeNull();
|
||||
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlPollReader.ReadAsync(refs, ct)`: resolve each ref → `SqlTagDefinition` (miss → `BadNodeIdUnknown`); `SqlGroupPlanner.Plan(...)`; per group under a `SemaphoreSlim(maxConcurrentGroups)`: `await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt)`; build `DbCommand` with `CommandTimeout = (int)commandTimeout.TotalSeconds` and bound params; `using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token)`; index rows by key/selector; map each member's cell → `DataValueSnapshot` (type per `DeclaredType` ?? `dialect.MapColumnType`-inferred; NULL → `nullIsBad ? Bad : Uncertain`; absent key → `BadNoData`; source timestamp from `timestampColumn` else read wall-clock). **Reassemble in input order** (`DataValueSnapshot[refs.Count]`). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). `await using` both connection and reader — never leak. Add `SqlStatusCodes` static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
|
||||
4. Run — expect PASS. Add a NULL-cell test with `nullIsBad:true` → `Bad`.
|
||||
5. Commit: `git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `SqlDriver` shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
|
||||
|
||||
**Classification:** high-risk (lifecycle + poll-engine wiring + connection validation)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — construct `SqlDriver` with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored `RawTagEntry` list; `InitializeAsync` → `Healthy`; `DiscoverAsync` streams one Variable per authored tag with `SecurityClass=ViewOnly` + `RediscoverPolicy=Once` + `SupportsOnlineDiscovery=false`; `ReadAsync` delegates to the reader; a subscribe fires an initial change:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
|
||||
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
var cap = new CapturingBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
|
||||
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable`. Mirror `ModbusDriver`: fields grouped up top; `_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...)`; `_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s)`. `InitializeAsync`: build `_tagsByRawPath` from `_options.RawTags` via `SqlEquipmentTagParser.TryParse` (miss = logged skip, never throw); open ONE connection, run `dialect.LivenessSql` under `CommandTimeout` + linked CTS, dispose; success → `Healthy`, failure → `Faulted` + `LastError`. `ReadAsync` → `_reader.ReadAsync`. `SubscribeAsync` → `_poll.Subscribe`; `UnsubscribeAsync` → `_poll.Unsubscribe`. `IHostConnectivityProbe`: single logical host (server host from the connection string) with `Running/Stopped` from the last poll/liveness outcome + `OnHostStatusChanged` on transitions. `GetMemoryFootprint => 0`; `FlushOptionalCachesAsync => Task.CompletedTask`; `ReinitializeAsync` = teardown+init. `DriverType => DriverTypeNames.Sql`. Add a `ForTest(...)` internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). **Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.**
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: `SqlDriverFactoryExtensions` + `connectionStringRef` env resolution + enum-serialization guard
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `CreateInstance` with a config carrying `"provider":"SqlServer"` builds a driver; a missing `connectionStringRef` throws a clean error; the connection-string ref resolves from env `Sql__ConnectionStrings__<ref>`; **enum-serialization guard** — a config author serializing `provider` as a NUMBER still parses (`JsonStringEnumConverter` accepts ordinals) AND the round-trip writes the NAME:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
||||
=> Should.Throw<InvalidOperationException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
|
||||
.Message.ShouldContain("connectionStringRef");
|
||||
|
||||
[Fact]
|
||||
public void ConnectionStringRef_resolvesFromEnv()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
|
||||
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_serializesAsNameNotNumber()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
|
||||
SqlDriverFactoryExtensions.JsonOptionsForTest);
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
json.ShouldNotContain("\"provider\":0");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlConnectionStringResolver.Resolve(string @ref)` → `Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")` (direct env read, deliberate — the factory closure has no `IConfiguration`, per design §8.2), throws actionable if absent. `SqlDriverFactoryExtensions`: `const DriverTypeName = DriverTypeNames.Sql`; `Register(registry, loggerFactory)` → `registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory))`; `CreateInstance` deserializes `SqlDriverConfigDto` with `JsonOptions` carrying `JsonStringEnumConverter` + `UnmappedMemberHandling.Skip`, validates `ConnectionStringRef` present, builds `SqlServerDialect` from `Provider` (P1: only `SqlServer` constructible; others throw "provider not available in this build"), resolves the connection string, constructs the `SqlPollReader` + `SqlDriver`. **Never log the resolved connection string** (log provider + server host + database only). Expose `JsonOptionsForTest` internal.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: `SqlDriverProbe` (SELECT 1 liveness, bounded timeout)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 9, Task 12
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — against the SQLite fixture (inject dialect+factory), `ProbeAsync` returns green with latency; a bad connection string returns a red result with the error message (never throws):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Probe_liveConnection_returnsGreen()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
|
||||
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
r.Success.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriverProbe : IDriverProbe`, `DriverType => DriverTypeNames.Sql`. Parse config via the SAME factory DTO shape + `JsonStringEnumConverter` (R2-11 factory parity, mirroring `ModbusDriverProbe`), resolve the connection string ref, open a connection under a linked CTS bounded by `timeout`, run `dialect.LivenessSql` (`SELECT 1`), return `DriverProbeResult(true, "SQL SELECT 1 OK", latency)`; catch → red result naming the failure. Never throws. `ForTest` injects factory+dialect for the SQLite path.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Host registration — `DriverTypeNames.Sql` + factory + probe + guard test
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (project-ref `Driver.Sql` if not transitively present)
|
||||
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs` is EXISTING — it asserts bidirectional parity between `DriverTypeNames` constants and the factory-registered set; this task makes it pass by adding both sides.
|
||||
|
||||
**TDD:**
|
||||
1. **Failing step** — add `public const string Sql = "Sql";` to `DriverTypeNames` + include in `All`, but DON'T yet register the factory. Run `DriverTypeNamesGuardTests` → expect FAIL (constant with no registered factory).
|
||||
2. **Impl** — in `DriverFactoryBootstrap.Register(...)` add `Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);`; in `AddOtOpcUaDriverProbes` add `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());` with a `using SqlProbe = Driver.Sql.SqlDriverProbe;` alias; add the `Driver.Sql` project-ref to the Host csproj. Default tier `DriverTier.A` (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so `ShouldStub()` needs no change).
|
||||
3. Run guard test + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Scaffold `Driver.Sql.Browser` project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 6, Task 9, Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
**Steps:**
|
||||
1. csproj refs `Commons` (`ZB.MOM.WW.OtOpcUa.Commons` — `IDriverBrowser`/`IBrowseSession`/`BrowseNode`) + `Driver.Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is the deliberate deviation from `OpcUaClient.Browser` recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. `InternalsVisibleTo` the `.Browser.Tests`.
|
||||
2. Add to slnx under `/src/Drivers/`.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Browser project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: `SqlBrowseSession` — schema walk over the dialect catalog
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj` (+ slnx entry, + link `SqliteDialect.cs` from Sql.Tests)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — over a seeded SQLite DB + `SqliteDialect`: `RootAsync` yields schema folder(s); `ExpandAsync(schema)` yields the seeded tables; `ExpandAsync(table)` yields columns as leaves; `AttributesAsync(column)` yields the mapped `DriverDataType`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
|
||||
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
|
||||
var cols = await session.ExpandAsync(tableNode.NodeId, default);
|
||||
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
|
||||
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
|
||||
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlBrowseSession : IBrowseSession` (match the `Commons.Browsing` interface shape used by `OpcUaClientBrowseSession`): `RootAsync`/`ExpandAsync(nodeId)`/`AttributesAsync(nodeId)` run the dialect catalog queries with bound `@schema`/`@table` params; encode `NodeId` as `schema` / `schema.table` / `schema.table|column`; a `SemaphoreSlim _gate` serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf `AttributesAsync` returns `AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")` (mirrors Galaxy two-stage pick).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: `SqlDriverBrowser` — IDriverBrowser open (env ref OR pasted literal, transient connection)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `OpenAsync` with a form JSON whose `connectionStringRef` resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
|
||||
{
|
||||
var browser = new SqlDriverBrowser();
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
|
||||
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriverBrowser : IDriverBrowser`, `DriverType => DriverTypeNames.Sql`. `OpenAsync(configJson, ct)`: deserialize with the same `JsonStringEnumConverter` options; resolve `connectionStringRef` via a direct env read (`Sql__ConnectionStrings__<ref>`) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal `connectionString` for ad-hoc browse (session-only, **never persisted, never logged, no `_lastConfigJson` cached field**); select `SqlServerDialect` (P1); open ONE transient `DbConnection`, return a `SqlBrowseSession`. Reuses the AdminUI `BrowseSessionRegistry` + reaper + idle TTL unchanged.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: AdminUI browser DI + `SqlAddressPickerBody.razor` (picker body)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` beside the OpcUaClient/Galaxy lines ~:75)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (project-ref `Driver.Sql.Browser`)
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor` (reuse `DriverBrowseTree.razor` for the tree + an attribute side-panel + model/selector fields; gated by the existing `DriverOperator` policy; manual entry retained)
|
||||
|
||||
**Steps (no unit test — Razor is live-verified in Task 21):**
|
||||
1. Register the bespoke `IDriverBrowser` (the universal-browser DI line is NOT added for `Sql` — its `CanBrowse` is false and bespoke wins by construction).
|
||||
2. `SqlAddressPickerBody.razor` — thin body: `DriverBrowseTree` in picker mode + attribute side-panel; on column pick, compose the `TagConfig` blob (DisplayName default `table.column`, prefill `type` from `AttributesAsync`, operator picks model + fills key/row selector; `SecurityClass=ViewOnly`).
|
||||
3. `dotnet build` — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Env-gated central-SQL integration fixture (`Driver.Sql.IntegrationTests`)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 13, Task 14, Task 15, Task 17
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj` (+ slnx entry)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing/skipping test** — against the always-on central SQL Server (`10.100.0.35,14330`), seed a `SqlPollFixture` database with the two sample tables (KeyValue + wide-row), then validate the real `Microsoft.Data.SqlClient` path (read round-trip, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation, pooling). **Env-gated** via `SQL_TEST_ENDPOINT` / a full `Sql__ConnectionStrings__Fixture` — absent ⇒ skip cleanly (use `Assert.Skip(...)` / xunit.v3 `[Fact(Skip=...)]` dynamic skip), like every other `*.IntegrationTests`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
|
||||
{
|
||||
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
|
||||
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
|
||||
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
|
||||
await driver.InitializeAsync(fx.ConfigJson, default);
|
||||
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
|
||||
snaps[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
```
|
||||
2. Run offline — expect SKIP (green).
|
||||
3. **Impl** — the fixture (create-if-missing DB + tables + seed rows via `Microsoft.Data.SqlClient`) and the read tests. **The seed DB is `SqlPollFixture`, NOT `ConfigDb`** — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
|
||||
4. Commit: `git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"`
|
||||
|
||||
---
|
||||
|
||||
## Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 13, Task 14, Task 15, Task 16
|
||||
**Files:**
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** (offline, SQLite fixture) — a malicious `keyValue` (`'; DROP TABLE TagValues; --`) reads harmlessly as a bound param and the seed table still exists afterward; a malicious `table`/`column` identifier that is not catalog-valid is REJECTED (tag → `BadNodeIdUnknown`), never executed:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var reader = fx.NewReader();
|
||||
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
|
||||
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
|
||||
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
|
||||
3. **Impl** — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
|
||||
4. Commit: `git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"`
|
||||
|
||||
---
|
||||
|
||||
## Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
|
||||
|
||||
**Classification:** high-risk (frozen-peer resilience — the single highest-value integration test)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml` (a dedicated disposable `mssql` stack)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs`
|
||||
|
||||
> **CRITICAL:** this gate `docker pause`s a **dedicated** `mcr.microsoft.com/mssql/server` container it owns — **NEVER** the shared central SQL Server on `10.100.0.35,14330`, which hosts `ConfigDb` for the whole rig (design §9). The compose stack is deployed via `lmxopcua-fix sync`; `lmxopcua-fix` applies the `project=lmxopcua` label host-side.
|
||||
|
||||
**TDD:**
|
||||
1. **Failing/skipping test** — mirror the R2-01 S7 blackhole (`S7_1500ConnectTimeoutOutageTests`): start reading against the dedicated mssql, then `docker pause` it mid-poll; assert the next read surfaces `BadTimeout` within `operationTimeout` + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; `docker unpause` recovers. Env-gated (`SQL_BLACKHOLE_ENDPOINT`) — offline skip:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
|
||||
{
|
||||
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
|
||||
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
|
||||
}
|
||||
```
|
||||
2. Run offline — expect SKIP.
|
||||
3. **Impl** — dedicated `docker-compose.yml` (`mssql` service, own container name, `restart: "no"`), `seed.sql` (the two sample tables), and the test that shells `docker pause`/`docker unpause` against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): `CommandTimeout` (server-side backstop) + `ExecuteReaderAsync(linkedCt)` bounded by `operationTimeout` (client-side real cancellation) both fire.
|
||||
4. Commit: `git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"`
|
||||
|
||||
---
|
||||
|
||||
## Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17, Task 18
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (add `[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (add `[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()`)
|
||||
- Test: `tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs` (locate the existing AdminUI tag-editor test project; mirror `ModbusTagConfigModel` tests)
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `FromJson`→`ToJson` round-trips the model, preserves unknown keys, writes enums (`model`/`type`) as NAME strings not numbers; `Validate()` rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
|
||||
{
|
||||
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"KeyValue\"");
|
||||
json.ShouldContain("\"Float64\"");
|
||||
json.ShouldContain("customX"); // unknown key survives load→save
|
||||
json.ShouldNotContain("\"model\":0");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlTagConfigModel` (thin, over `TagConfigJson.ParseOrNew`, preserving the `_bag`): `Model`, `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn`, `ColumnName` + `RowSelector`, `Type` (`DriverDataType?`), `FromJson`/`ToJson` (enums as name strings via `TagConfigJson.Set`) `/Validate` (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in `TagConfigEditorMap` + `TagConfigValidator`. **Enum-serialization trap (systemic):** `model`/`type` MUST round-trip as strings on both sides — the editor `ToJson` and the factory `SqlEquipmentTagParser` both use string enums; add the assertion above.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 20: `SqlTagConfigEditor.razor` (thin shell over the model)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor`
|
||||
|
||||
**Steps (Razor — live-verified in Task 21):**
|
||||
1. Copy the Modbus editor template; bind over `SqlTagConfigModel`: a `Model` dropdown (KeyValue/WideRow) that shows the matching field group, `Table`, the key/value/timestamp fields (KeyValue) OR `ColumnName` + row-selector fields (WideRow), and a `Type` override dropdown. `@bind` string component params need the `@` prefix (the Global-UNS gotcha).
|
||||
2. `dotnet build` — expect PASS.
|
||||
3. Commit: `git commit -m "feat(sql): SqlTagConfigEditor razor shell"`
|
||||
|
||||
---
|
||||
|
||||
## Task 21: Live `/run` verification on docker-dev (picker + editor + deploy + read)
|
||||
|
||||
**Classification:** trivial (procedure — no product code; may surface fix-forward tasks)
|
||||
**Estimated implement time:** ~5 min (verification, excluding any bugs found)
|
||||
**Parallelizable with:** none (last)
|
||||
**Files:** none (verification only; any defect becomes a new fix-forward task)
|
||||
|
||||
**Procedure (docker-dev rig, AdminUI auto-authenticated at `http://localhost:9200` — login disabled, so drive it yourself, don't defer to the user):**
|
||||
1. Set `Sql__ConnectionStrings__DevSql` on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (`:9200` round-robins).
|
||||
2. In `/raw`, add a `Sql` driver + device (paste a literal connection string or use the ref); Test-Connect → green (`SELECT 1`).
|
||||
3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
|
||||
4. Reference the raw tags into an equipment on `/uns`; deploy via `POST :9200/api/deployments` (X-Api-Key) or the deploy button; confirm the deployment seals green.
|
||||
5. Read via Client.CLI (`read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"`) — confirm the live SQL value + quality + source timestamp.
|
||||
6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
|
||||
|
||||
---
|
||||
|
||||
## Deferred / out of scope (pointers to the design, NOT executable tasks here)
|
||||
|
||||
These are recorded so nobody re-derives them; each is a later phase in `docs/plans/2026-07-15-sql-poll-driver-design.md`. The `ISqlDialect` seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
|
||||
|
||||
- **Named-query model** (design §5.4, §3.6(c) / design-P3) — the arbitrary-`SELECT` escape hatch (`queries: {...}` + a tag referencing a query by name + projection). Not built in v1; the `SqlTagModel.Query` enum member exists but the reader/planner path is deferred.
|
||||
- **PostgreSQL + ODBC dialects** (design §2.2, §8.5 / design-P3) — `PostgresDialect` (`Npgsql`) + `OdbcDialect` (`System.Data.Odbc`), each a NEW gated `PackageVersion`. v1 constructs only `SqlServerDialect`; the other `SqlProvider` members throw "provider not available in this build."
|
||||
- **MySQL/MariaDB + native Oracle** (design §10 / design-P5) — `MySqlConnector` + an `ALL_TAB_COLUMNS` Oracle dialect. Demand-driven.
|
||||
- **Write mode (`IWritable`)** (design §3.4 / design-P4) — opt-in, triple-gated (`WriteOperate` role + driver `allowWrites` master switch + `TagConfig.writable`), parameterized UPSERT/StoredProc, `WriteIdempotent` retry policy. v1 is read-only; `SecurityClass=ViewOnly` everywhere and `allowWrites` defaults `false`.
|
||||
- **Split-role env-parity operational note** (design §4.4) — admin nodes must carry the same `Sql__ConnectionStrings__<ref>` env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
|
||||
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
|
||||
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
|
||||
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
|
||||
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
|
||||
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -79,4 +79,46 @@ public sealed class AkkaClusterOptions
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string SplitBrainResolverStrategy { get; set; } = "auto-down";
|
||||
|
||||
/// <summary>
|
||||
/// The simultaneous-cold-start split-brain guard (<c>Cluster:BootstrapGuard</c>). Default OFF — a
|
||||
/// dark switch, so existing deployments and tests keep Akka's config-driven self-first auto-join
|
||||
/// unchanged. See <see cref="ClusterBootstrapGuard"/> for the decision logic and
|
||||
/// <c>ClusterBootstrapCoordinator</c> for the runtime.
|
||||
/// </summary>
|
||||
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the simultaneous-cold-start split-brain guard. When
|
||||
/// <see cref="Enabled"/>, the node does NOT auto-join from its config seeds; a coordinator picks the
|
||||
/// join order (founder self-first / joiner peer-first) after a reachability probe. See
|
||||
/// <see cref="ClusterBootstrapGuard"/>.
|
||||
/// </summary>
|
||||
public sealed class ClusterBootstrapGuardOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether the guard is active. Default <see langword="false"/> — Akka auto-joins from
|
||||
/// the config seed list exactly as before. Only meaningful on a node that is one of its own two
|
||||
/// pair seeds; inert everywhere else.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how long the higher-address node probes its partner's Akka endpoint before
|
||||
/// concluding the partner is dead and forming alone. Must comfortably exceed the partner's
|
||||
/// worst-case process-start-to-Akka-bind time so a slow-but-alive partner is never mistaken for a
|
||||
/// dead one (which would re-open the split). Default 25 s.
|
||||
/// </summary>
|
||||
public int PartnerProbeSeconds { get; set; } = 25;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval between partner reachability probes, in milliseconds. Default 500 ms.
|
||||
/// </summary>
|
||||
public int PartnerProbeIntervalMs { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-probe TCP connect timeout, in milliseconds. Default 1000 ms.
|
||||
/// </summary>
|
||||
public int ProbeConnectTimeoutMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
ValidateBootstrapGuard(builder, options.BootstrapGuard);
|
||||
|
||||
var seeds = options.SeedNodes ?? Array.Empty<string>();
|
||||
if (seeds.Length == 0)
|
||||
{
|
||||
@@ -68,6 +70,25 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
||||
+ "the entries — see docs/Redundancy.md → 'Bootstrap: self-first seed ordering'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bootstrap guard is enabled, its timing knobs must be positive. A zero/negative
|
||||
/// <c>PartnerProbeSeconds</c> in particular silently degrades the guard to "never wait, always
|
||||
/// conclude the partner is dead" — it would form alone immediately and re-open the very split it
|
||||
/// exists to close. Fail-fast at boot rather than producing silence (the same rationale every
|
||||
/// sibling options validator in this project cites).
|
||||
/// </summary>
|
||||
private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions guard)
|
||||
{
|
||||
if (guard is null || !guard.Enabled) return;
|
||||
|
||||
if (guard.PartnerProbeSeconds <= 0)
|
||||
builder.Add($"Cluster:BootstrapGuard:PartnerProbeSeconds must be > 0 when the guard is enabled (was {guard.PartnerProbeSeconds}).");
|
||||
if (guard.PartnerProbeIntervalMs <= 0)
|
||||
builder.Add($"Cluster:BootstrapGuard:PartnerProbeIntervalMs must be > 0 when the guard is enabled (was {guard.PartnerProbeIntervalMs}).");
|
||||
if (guard.ProbeConnectTimeoutMs <= 0)
|
||||
builder.Add($"Cluster:BootstrapGuard:ProbeConnectTimeoutMs must be > 0 when the guard is enabled (was {guard.ProbeConnectTimeoutMs}).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="seed"/> addresses this node itself — host AND port.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the simultaneous-cold-start split-brain guard: when <c>Cluster:BootstrapGuard:Enabled</c>, the
|
||||
/// node starts with NO config seed nodes (see <c>BuildClusterOptions</c>), and this coordinator picks
|
||||
/// the join order and issues the single <see cref="Akka.Cluster.Cluster.JoinSeedNodes"/> once the
|
||||
/// ActorSystem is up. See <see cref="ClusterBootstrapGuard"/> for the decision logic and the
|
||||
/// split-brain rationale.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The founder (lower canonical address) joins its self-first order immediately. The higher
|
||||
/// node probes its partner's Akka endpoint (a plain TCP connect — the partner has bound its
|
||||
/// port well before it forms) up to <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/>:
|
||||
/// reachable ⇒ peer-first (join the founder); unreachable ⇒ self-first (partner is dead, form
|
||||
/// alone). The join runs on a background task so it never blocks host startup, and it is issued
|
||||
/// exactly once — the coordinator never re-forms a node mid-handshake, the failure mode of the
|
||||
/// retired <c>SelfFormAfter</c> watchdog.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClusterBootstrapCoordinator : IHostedService
|
||||
{
|
||||
private readonly Func<ActorSystem> _system;
|
||||
private readonly AkkaClusterOptions _options;
|
||||
private readonly ILogger<ClusterBootstrapCoordinator> _log;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _joinTask;
|
||||
|
||||
/// <summary>Creates the coordinator.</summary>
|
||||
/// <param name="system">Lazy accessor for the node's ActorSystem (resolved after Akka's hosted service starts it).</param>
|
||||
/// <param name="options">The bound cluster options (seed list + guard settings).</param>
|
||||
/// <param name="log">Logger for the bootstrap decision.</param>
|
||||
public ClusterBootstrapCoordinator(
|
||||
Func<ActorSystem> system, IOptions<AkkaClusterOptions> options, ILogger<ClusterBootstrapCoordinator> log)
|
||||
{
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.BootstrapGuard.Enabled)
|
||||
return Task.CompletedTask; // dark switch off — Akka auto-joined from config seeds already.
|
||||
|
||||
// Fire-and-forget so a higher node with a dead partner (which waits the full probe window)
|
||||
// never blocks host startup. Exceptions are logged; a failed join leaves the node unjoined,
|
||||
// which is visible and recoverable, never a silent split.
|
||||
_joinTask = Task.Run(() => RunAsync(_cts.Token), _cts.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
if (_joinTask is not null)
|
||||
{
|
||||
try { await _joinTask.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* expected on shutdown */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var seeds = _options.SeedNodes ?? Array.Empty<string>();
|
||||
var role = ClusterBootstrapGuard.Analyze(seeds, _options.PublicHostname, _options.Port);
|
||||
|
||||
string[] order;
|
||||
var committedPeerFirst = false;
|
||||
if (!role.Applies)
|
||||
{
|
||||
// Not a pair seed (single-seed legacy site node, self absent, malformed, or 3+ seeds):
|
||||
// join the configured seeds unchanged — the guard arbitrates only the 2-node pair race.
|
||||
order = seeds;
|
||||
_log.LogInformation(
|
||||
"Bootstrap guard: not a pair seed ({SeedCount} seed(s)); joining configured seeds unchanged.",
|
||||
seeds.Length);
|
||||
}
|
||||
else if (role.IsFounder)
|
||||
{
|
||||
order = role.SelfFirstOrder;
|
||||
_log.LogInformation(
|
||||
"Bootstrap guard: this node is the preferred founder (lower address); joining self-first, forming immediately if no peer answers.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var reachable = await ProbePartnerAsync(role.PartnerHost!, role.PartnerPort, ct).ConfigureAwait(false);
|
||||
order = ClusterBootstrapGuard.HigherNodeOrder(role, reachable);
|
||||
committedPeerFirst = reachable;
|
||||
_log.LogInformation(
|
||||
"Bootstrap guard: this node is the higher address; partner {PartnerHost}:{PartnerPort} was {Reachability} within the probe window; joining {Order}.",
|
||||
role.PartnerHost, role.PartnerPort, reachable ? "REACHABLE" : "UNREACHABLE",
|
||||
reachable ? "peer-first (join the founder)" : "self-first (partner down, forming alone)");
|
||||
}
|
||||
|
||||
if (order.Length == 0)
|
||||
{
|
||||
_log.LogWarning("Bootstrap guard: no seed nodes to join; node stays unjoined until a seed is configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cluster = Akka.Cluster.Cluster.Get(_system());
|
||||
var addresses = order.Select(Address.Parse).ToArray();
|
||||
cluster.JoinSeedNodes(addresses);
|
||||
|
||||
// Peer-first is a one-way commitment: JoinSeedNodeProcess never self-forms. If the founder
|
||||
// died in the probe→join window, this node hangs unjoined forever. We do NOT re-form here
|
||||
// (the retired SelfFormAfter mid-handshake failure mode) — we make the hang operator-visible
|
||||
// so a restart, which re-runs the guard against the now-dead founder and self-forms, recovers
|
||||
// it. Nothing is done on the founder / self-first paths, which always self-form on their own.
|
||||
if (committedPeerFirst)
|
||||
await WarnIfNotUpAsync(cluster, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Host shutting down before the join completed — nothing to do.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Bootstrap guard: failed to issue JoinSeedNodes; node remains unjoined.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After committing peer-first, waits a bounded time for this node to reach <c>Up</c> and logs a
|
||||
/// clear warning if it does not — the founder must have died in the probe→join window, leaving this
|
||||
/// node hung in <c>JoinSeedNodeProcess</c>. Read-only: it never re-forms or re-joins (that is the
|
||||
/// retired mid-handshake failure mode); it only surfaces the condition so an operator restarts the node.
|
||||
/// </summary>
|
||||
private async Task WarnIfNotUpAsync(Akka.Cluster.Cluster cluster, CancellationToken ct)
|
||||
{
|
||||
// Give the join a generous grace: the founder's own self-form (seed-node-timeout) plus this
|
||||
// node's join round-trip. Two probe windows is comfortably beyond both.
|
||||
var grace = TimeSpan.FromSeconds(Math.Max(10, _options.BootstrapGuard.PartnerProbeSeconds * 2));
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (!ct.IsCancellationRequested && sw.Elapsed < grace)
|
||||
{
|
||||
if (cluster.SelfMember.Status == MemberStatus.Up) return; // joined — all good
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested && cluster.SelfMember.Status != MemberStatus.Up)
|
||||
_log.LogWarning(
|
||||
"Bootstrap guard: committed peer-first but this node is still {Status} after {Grace}s — its founder likely died in the probe→join window. This node will NOT self-form on its own (peer-first never does). RESTART it to recover: the guard will re-run, find the founder down, and form alone.",
|
||||
cluster.SelfMember.Status, (int)grace.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the partner's Akka endpoint with a plain TCP connect until it is reachable or the probe
|
||||
/// window elapses. Reachable-at-TCP is a sound proxy for "the partner is coming up": a node binds
|
||||
/// its Akka port near the start of startup, well before it forms or joins a cluster.
|
||||
/// </summary>
|
||||
private async Task<bool> ProbePartnerAsync(string host, int port, CancellationToken ct)
|
||||
{
|
||||
var window = TimeSpan.FromSeconds(Math.Max(0, _options.BootstrapGuard.PartnerProbeSeconds));
|
||||
var interval = TimeSpan.FromMilliseconds(Math.Max(50, _options.BootstrapGuard.PartnerProbeIntervalMs));
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (await TryConnectAsync(host, port, ct).ConfigureAwait(false)) return true;
|
||||
if (sw.Elapsed >= window) return false;
|
||||
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return false; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TryConnectAsync(string host, int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Math.Max(100, _options.BootstrapGuard.ProbeConnectTimeoutMs));
|
||||
await client.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
|
||||
return client.Connected;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw; // host shutdown — propagate
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // connect refused / timed out / DNS not resolvable yet — partner not up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for the simultaneous-cold-start split-brain guard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both nodes of a 2-node pair are self-first seeds so <b>either</b> can cold-start alone when
|
||||
/// its partner is dead (see <see cref="AkkaClusterOptions.SeedNodes"/>). The cost is that when
|
||||
/// BOTH cold-start at the same instant, each runs Akka's <c>FirstSeedNodeProcess</c>, times out
|
||||
/// waiting for the other, and forms its OWN single-node cluster — a split brain (two Primaries
|
||||
/// in one pair). Two independent clusters do not auto-merge, so the split persists until an
|
||||
/// operator intervenes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This guard breaks the symmetry deterministically without giving up cold-start-alone. The
|
||||
/// node with the lexicographically <b>lower</b> canonical address is the <i>preferred
|
||||
/// founder</i>: it always uses self-first order and forms immediately. The <b>higher</b> node
|
||||
/// first probes whether its partner's Akka endpoint is reachable (see
|
||||
/// <see cref="ClusterBootstrapCoordinator"/>): reachable ⇒ peer-first order so it JOINS the
|
||||
/// founder rather than racing it; unreachable after the probe window ⇒ the partner is genuinely
|
||||
/// down, so it falls back to self-first and forms alone. Exactly one node founds when both are
|
||||
/// present; the lower node always founds when it starts alone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Residual trade-off (accepted).</b> Once the higher node observes the partner reachable it
|
||||
/// commits to peer-first — Akka's <c>JoinSeedNodeProcess</c> retries <c>InitJoin</c> forever and
|
||||
/// never self-forms. If the founder dies in the small window between the probe succeeding and
|
||||
/// the join completing, the higher node hangs unjoined until it is restarted (a restart re-runs
|
||||
/// the guard, finds the founder unreachable, and self-forms). We deliberately do NOT re-decide
|
||||
/// mid-handshake — that is exactly the retired <c>SelfFormAfter</c> failure mode. Instead
|
||||
/// <see cref="ClusterBootstrapCoordinator"/> makes the hang operator-visible with a warning if
|
||||
/// the node has not come Up within a bounded time after committing peer-first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decides the join order BEFORE issuing a single <c>JoinSeedNodes</c>, from an explicit
|
||||
/// reachability signal — unlike the retired <c>SelfFormAfter</c> watchdog, which fired a
|
||||
/// <c>Join(self)</c> mid-handshake on a bare timeout and could not tell "no seed answered" from
|
||||
/// "a join is in flight", islanding a node a failover had just bounced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ClusterBootstrapGuard
|
||||
{
|
||||
private static readonly Regex SeedPattern =
|
||||
new(@"^akka(?:\.[a-z0-9]+)?://[^@/]+@(?<host>[^:/]+):(?<port>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>The analyzed bootstrap role of this node within its seed list.</summary>
|
||||
/// <param name="Applies">True only when the guard engages: exactly two seeds, one of them this node.</param>
|
||||
/// <param name="IsFounder">True when this node is the preferred founder (lower address) — form immediately, no probe.</param>
|
||||
/// <param name="PartnerHost">The partner's advertised host (to probe when this node is the higher one); null when not applicable.</param>
|
||||
/// <param name="PartnerPort">The partner's Akka port.</param>
|
||||
/// <param name="SelfFirstOrder"><c>[self, partner]</c> — self-first; forms a new cluster when no peer answers.</param>
|
||||
/// <param name="PeerFirstOrder"><c>[partner, self]</c> — peer-first; joins the partner's cluster, never self-forms while it is up.</param>
|
||||
public sealed record BootstrapRole(
|
||||
bool Applies,
|
||||
bool IsFounder,
|
||||
string? PartnerHost,
|
||||
int PartnerPort,
|
||||
string[] SelfFirstOrder,
|
||||
string[] PeerFirstOrder);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
|
||||
/// for the Phase-6 pair shape (exactly two seeds, one of them this node); any other shape
|
||||
/// (single-seed legacy site node, three+ seeds, self absent) returns <see cref="BootstrapRole.Applies"/>
|
||||
/// = false and the caller joins the configured seeds unchanged.
|
||||
/// </summary>
|
||||
/// <param name="seeds">The configured seed URIs (self-first by convention, but order is not relied on here).</param>
|
||||
/// <param name="selfHost">This node's advertised host (<see cref="AkkaClusterOptions.PublicHostname"/>).</param>
|
||||
/// <param name="selfPort">This node's Akka port (<see cref="AkkaClusterOptions.Port"/>).</param>
|
||||
/// <returns>The analyzed role; never null.</returns>
|
||||
public static BootstrapRole Analyze(IReadOnlyList<string> seeds, string selfHost, int selfPort)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seeds);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selfHost);
|
||||
|
||||
var na = new BootstrapRole(false, false, null, 0, Array.Empty<string>(), Array.Empty<string>());
|
||||
if (seeds.Count != 2) return na;
|
||||
|
||||
var selfKey = Key(selfHost, selfPort);
|
||||
string? self = null, partner = null;
|
||||
string? partnerHost = null;
|
||||
var partnerPort = 0;
|
||||
|
||||
foreach (var seed in seeds)
|
||||
{
|
||||
if (!TryParse(seed, out var host, out var port)) return na;
|
||||
if (string.Equals(Key(host, port), selfKey, StringComparison.OrdinalIgnoreCase))
|
||||
self = seed;
|
||||
else
|
||||
{
|
||||
partner = seed;
|
||||
partnerHost = host;
|
||||
partnerPort = port;
|
||||
}
|
||||
}
|
||||
|
||||
// Self must be exactly one of the two, and the other must be a distinct partner.
|
||||
if (self is null || partner is null || partnerHost is null) return na;
|
||||
|
||||
var selfFirst = new[] { self, partner };
|
||||
var peerFirst = new[] { partner, self };
|
||||
|
||||
// Deterministic tie-break: the lower canonical address is the preferred founder. Both nodes
|
||||
// compute the same comparison (same two seeds), so exactly one is the founder. Case-INSENSITIVE
|
||||
// to match how self/partner are classified above (and AkkaClusterOptionsValidator.IsSelf):
|
||||
// container/DNS hostnames are conventionally case-insensitive, and a casing difference between
|
||||
// the two sides' seed config must NOT make both think they are the founder (which would reopen
|
||||
// the very split this guard closes).
|
||||
var isFounder = string.Compare(
|
||||
Key(selfHost, selfPort),
|
||||
Key(partnerHost, partnerPort),
|
||||
StringComparison.OrdinalIgnoreCase) < 0;
|
||||
|
||||
return new BootstrapRole(true, isFounder, partnerHost, partnerPort, selfFirst, peerFirst);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The order the higher (non-founder) node uses once it has learned whether its partner is
|
||||
/// reachable: peer-first when the founder is up (join it), self-first when the founder is absent
|
||||
/// (form alone — cold-start-alone preserved).
|
||||
/// </summary>
|
||||
/// <param name="role">The analyzed role (must be applicable and NOT the founder).</param>
|
||||
/// <param name="partnerReachable">Whether the partner's Akka endpoint became reachable within the probe window.</param>
|
||||
/// <returns>The seed order to pass to <c>Cluster.JoinSeedNodes</c>.</returns>
|
||||
public static string[] HigherNodeOrder(BootstrapRole role, bool partnerReachable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(role);
|
||||
return partnerReachable ? role.PeerFirstOrder : role.SelfFirstOrder;
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>akka.tcp://system@host:port[/...]</c> into host + port.</summary>
|
||||
/// <param name="seed">The seed URI.</param>
|
||||
/// <param name="host">The parsed advertised host.</param>
|
||||
/// <param name="port">The parsed Akka port.</param>
|
||||
/// <returns>True when the seed matched the expected shape.</returns>
|
||||
public static bool TryParse(string? seed, out string host, out int port)
|
||||
{
|
||||
host = string.Empty;
|
||||
port = 0;
|
||||
if (string.IsNullOrWhiteSpace(seed)) return false;
|
||||
|
||||
var m = SeedPattern.Match(seed.Trim());
|
||||
if (!m.Success) return false;
|
||||
if (!int.TryParse(m.Groups["port"].Value, out port)) return false;
|
||||
host = m.Groups["host"].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Key(string host, int port) => $"{host}:{port}";
|
||||
}
|
||||
@@ -241,7 +241,13 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return new ClusterOptions
|
||||
{
|
||||
SeedNodes = options.SeedNodes,
|
||||
// When the bootstrap guard is on, DO NOT hand Akka the seed list: Akka.Cluster.Hosting
|
||||
// auto-joins from ClusterOptions.SeedNodes the instant the ActorSystem starts, which is
|
||||
// exactly the self-first race the guard exists to arbitrate. Left empty, the node starts
|
||||
// unjoined and ClusterBootstrapCoordinator picks the order (founder self-first / joiner
|
||||
// peer-first, after a reachability probe) and issues the single Cluster.JoinSeedNodes.
|
||||
// The config seed list is still read — by the coordinator, off AkkaClusterOptions.SeedNodes.
|
||||
SeedNodes = options.BootstrapGuard.Enabled ? Array.Empty<string>() : options.SeedNodes,
|
||||
Roles = options.Roles,
|
||||
SplitBrainResolver = IsKeepOldest(options) ? new KeepOldestOption { DownIfAlone = true } : null,
|
||||
};
|
||||
|
||||
@@ -41,9 +41,100 @@ public static class DraftValidator
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateEquipReferenceResolution(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||
/// at the deploy gate (Gitea #498).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
||||
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
|
||||
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
|
||||
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
|
||||
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
|
||||
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
|
||||
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
|
||||
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
||||
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
||||
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
||||
/// device blob lands in exactly the same place.</para>
|
||||
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
|
||||
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
|
||||
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
|
||||
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
|
||||
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
|
||||
/// log and the audit trail; repeating the offending string there would leak the very credential the
|
||||
/// rule is refusing to store.</para>
|
||||
/// </remarks>
|
||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
const string ForbiddenKey = "connectionString";
|
||||
|
||||
var sqlInstanceIds = draft.DriverInstances
|
||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (sqlInstanceIds.Count == 0) return;
|
||||
|
||||
foreach (var d in draft.DriverInstances)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
||||
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
||||
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
||||
"from the environment / secret store at Initialize; a literal connection string here would be " +
|
||||
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
|
||||
"Remove the key and set 'connectionStringRef'.",
|
||||
d.DriverInstanceId));
|
||||
}
|
||||
|
||||
foreach (var dev in draft.Devices)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
||||
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
||||
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
||||
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
|
||||
dev.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
|
||||
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
|
||||
/// and shaping the config JSON is another rule's job.
|
||||
/// </summary>
|
||||
/// <param name="json">The config blob to inspect.</param>
|
||||
/// <param name="key">The property name to look for.</param>
|
||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
||||
private static bool HasTopLevelKey(string? json, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <list type="number">
|
||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||
|
||||
@@ -53,6 +53,9 @@ public static class DriverTypeNames
|
||||
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||
public const string Calculation = "Calculation";
|
||||
|
||||
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
|
||||
public const string Sql = "Sql";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -68,5 +71,6 @@ public static class DriverTypeNames
|
||||
OpcUaClient,
|
||||
Galaxy,
|
||||
Calculation,
|
||||
Sql,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// severity, the rendered message template and the last-observed input quality, i.e. everything
|
||||
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||
/// the next transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
|
||||
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
|
||||
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
|
||||
/// transition to report), so the transition stream cannot carry the current state — that is
|
||||
/// exactly why the caller needs this. Routing these through the emission path instead would
|
||||
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
|
||||
/// intentionally one the emission machinery cannot consume.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
|
||||
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
|
||||
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
|
||||
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
|
||||
/// read publishes its own transition through the normal path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
|
||||
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
|
||||
{
|
||||
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
projections.Add(new ScriptedAlarmProjection(
|
||||
AlarmId: alarmId,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: state.Condition,
|
||||
WorstInputStatusCode: LastWorstStatus(alarmId)));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
|
||||
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
|
||||
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
|
||||
/// for a future refactor to mistake for a transition.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
|
||||
/// <param name="Severity">The definition's severity bucket.</param>
|
||||
/// <param name="Message">The message template resolved against the current value cache.</param>
|
||||
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
|
||||
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
|
||||
public sealed record ScriptedAlarmProjection(
|
||||
string AlarmId,
|
||||
AlarmSeverity Severity,
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
uint WorstInputStatusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
/// engine's so Stream G can compose them behind one driver bridge.
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
|
||||
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
tcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x800B0000u, // BadTimeout
|
||||
StatusCode: StatusCodeMap.BadTimeout,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
|
||||
{
|
||||
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
|
||||
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
|
||||
//
|
||||
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
|
||||
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
|
||||
// originally written as though the DA byte could be shifted into the substatus position, which
|
||||
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
|
||||
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
|
||||
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
|
||||
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
|
||||
// against the pinned SDK's Opc.Ua.StatusCodes.
|
||||
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodLocalOverride = 0x00D80000u;
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
public const uint UncertainLastUsableValue = 0x40A40000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x408D0000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
|
||||
public const uint UncertainSubNormal = 0x408F0000u;
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
public const uint Bad = 0x80000000u;
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
|
||||
/// <summary>
|
||||
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
|
||||
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
|
||||
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
|
||||
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
|
||||
/// </summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
|
||||
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
|
||||
|
||||
+18
-18
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
192 => HistorianStatusCodes.Good,
|
||||
216 => HistorianStatusCodes.GoodLocalOverride,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
64 => HistorianStatusCodes.Uncertain,
|
||||
68 => HistorianStatusCodes.UncertainLastUsableValue,
|
||||
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
|
||||
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
|
||||
88 => HistorianStatusCodes.UncertainSubNormal,
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
0 => HistorianStatusCodes.Bad,
|
||||
4 => HistorianStatusCodes.BadConfigurationError,
|
||||
8 => HistorianStatusCodes.BadNotConnected,
|
||||
12 => HistorianStatusCodes.BadDeviceFailure,
|
||||
16 => HistorianStatusCodes.BadSensorFailure,
|
||||
20 => HistorianStatusCodes.BadCommunicationError,
|
||||
24 => HistorianStatusCodes.BadOutOfService,
|
||||
32 => HistorianStatusCodes.BadWaitingForInitialData,
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
_ when q >= 192 => HistorianStatusCodes.Good,
|
||||
_ when q >= 64 => HistorianStatusCodes.Uncertain,
|
||||
_ => HistorianStatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes, as named constants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
|
||||
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
|
||||
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
|
||||
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
|
||||
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
|
||||
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
|
||||
/// A literal written at a call site is invisible to that guard, which is exactly how
|
||||
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
|
||||
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
|
||||
/// </remarks>
|
||||
internal static class HistorianStatusCodes
|
||||
{
|
||||
// ---- Good family ----
|
||||
|
||||
/// <summary>The value is good; no qualification.</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
|
||||
// ---- Uncertain family ----
|
||||
|
||||
/// <summary>The value is uncertain; no specific reason.</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
|
||||
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
|
||||
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
|
||||
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
|
||||
// ---- Bad family ----
|
||||
|
||||
/// <summary>The value is bad; no specific reason.</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
|
||||
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>The source is not connected (OPC DA quality 8).</summary>
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
|
||||
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
|
||||
public const uint BadSensorFailure = 0x808C0000u;
|
||||
|
||||
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>The source is out of service (OPC DA quality 24).</summary>
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
|
||||
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>
|
||||
/// The historian returned no data for the requested tag/interval. Distinct from a transport
|
||||
/// failure: the query succeeded and the answer was empty.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// </summary>
|
||||
internal static class SampleMapper
|
||||
{
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
private const uint StatusBadNoData = 0x800E0000u;
|
||||
private const uint StatusGood = HistorianStatusCodes.Good;
|
||||
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
|
||||
|
||||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||
private const byte GoodQualityFloor = 192;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>Which catalog level a browse <c>NodeId</c> addresses.</summary>
|
||||
public enum SqlBrowseNodeKind
|
||||
{
|
||||
/// <summary>A schema — the browse root level.</summary>
|
||||
Schema,
|
||||
|
||||
/// <summary>A table or view inside a schema.</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>A column of a table or view — the terminal (leaf) level.</summary>
|
||||
Column,
|
||||
}
|
||||
|
||||
/// <summary>A decoded browse <c>NodeId</c>.</summary>
|
||||
/// <param name="Kind">Which catalog level this reference addresses.</param>
|
||||
/// <param name="Schema">The schema name. Always present.</param>
|
||||
/// <param name="Table">The table/view name; <c>null</c> for <see cref="SqlBrowseNodeKind.Schema"/>.</param>
|
||||
/// <param name="Column">The column name; <c>null</c> unless <see cref="SqlBrowseNodeKind.Column"/>.</param>
|
||||
public readonly record struct SqlBrowseNodeRef(
|
||||
SqlBrowseNodeKind Kind,
|
||||
string Schema,
|
||||
string? Table,
|
||||
string? Column);
|
||||
|
||||
/// <summary>
|
||||
/// Codec for the browse <c>NodeId</c>s the SQL schema browser hands the picker, and the picker hands back
|
||||
/// on expand / attribute fetch / column commit.
|
||||
/// <para><b>Why not the design's literal <c>schema.table|column</c>.</b> That sketch assumes <c>.</c> and
|
||||
/// <c>|</c> cannot occur inside an identifier. They can: SQL Server permits essentially any character
|
||||
/// inside a quoted (bracketed) identifier, and <c>SqlServerDialect.QuoteIdentifier</c> deliberately passes
|
||||
/// both through — only <c>]</c>, control characters and Unicode format characters are special to it. So
|
||||
/// <c>main.a.b|c</c> decodes equally as schema <c>main</c> + table <c>a.b</c> and as schema <c>main.a</c>
|
||||
/// + table <c>b</c>, and a table named <c>x|y</c> loses its second half entirely. That is not a cosmetic
|
||||
/// bug: the operator clicks one column, the NodeId decodes to a different one, and the tag they author
|
||||
/// polls the wrong data forever — silently, because the wrong column usually exists and usually reads.
|
||||
/// </para>
|
||||
/// <para><b>The encoding.</b> <c><kind>:<part>[|<part>…]</c>, where <c>kind</c> is one
|
||||
/// of <c>schema</c> / <c>table</c> / <c>column</c> and every part is escaped so no identifier character
|
||||
/// can be mistaken for structure: a literal <c>\</c> becomes <c>\\</c> and a literal <c>|</c> becomes
|
||||
/// <c>\|</c>. <c>.</c> needs no escape at all — it is not structural here — so the common case stays
|
||||
/// readable (<c>column:dbo|TagValues|num_value</c>). The kind prefix carries the arity, so decoding never
|
||||
/// has to guess how many parts a name "should" have; a part count that disagrees with the kind is
|
||||
/// rejected rather than truncated or padded.</para>
|
||||
/// <para><b>Public on purpose</b>, unlike the session itself: the AdminUI picker body decodes a committed
|
||||
/// column NodeId back into schema/table/column to compose the tag's <c>TagConfig</c>. Encode and decode
|
||||
/// must stay in one place — a second, hand-rolled split in the picker is exactly the defect this type
|
||||
/// exists to prevent.</para>
|
||||
/// </summary>
|
||||
public static class SqlBrowseNodeId
|
||||
{
|
||||
private const char Separator = '|';
|
||||
private const char Escape = '\\';
|
||||
private const char KindTerminator = ':';
|
||||
|
||||
private const string SchemaKind = "schema";
|
||||
private const string TableKind = "table";
|
||||
private const string ColumnKind = "column";
|
||||
|
||||
/// <summary>Encodes a schema-level NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="schema"/> is null, empty or whitespace.</exception>
|
||||
public static string ForSchema(string schema) => Encode(SchemaKind, schema);
|
||||
|
||||
/// <summary>Encodes a table-level NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException">Either name is null, empty or whitespace.</exception>
|
||||
public static string ForTable(string schema, string table) => Encode(TableKind, schema, table);
|
||||
|
||||
/// <summary>Encodes a column-level (leaf) NodeId.</summary>
|
||||
/// <param name="schema">The schema name, exactly as the catalog reports it.</param>
|
||||
/// <param name="table">The table or view name, exactly as the catalog reports it.</param>
|
||||
/// <param name="column">The column name, exactly as the catalog reports it.</param>
|
||||
/// <returns>The encoded NodeId.</returns>
|
||||
/// <exception cref="ArgumentException">Any name is null, empty or whitespace.</exception>
|
||||
public static string ForColumn(string schema, string table, string column) =>
|
||||
Encode(ColumnKind, schema, table, column);
|
||||
|
||||
/// <summary>Attempts to decode a NodeId.</summary>
|
||||
/// <param name="nodeId">The NodeId to decode.</param>
|
||||
/// <param name="reference">The decoded reference on success; <c>default</c> otherwise.</param>
|
||||
/// <returns><c>true</c> when <paramref name="nodeId"/> is a well-formed SQL browse NodeId.</returns>
|
||||
public static bool TryParse(string? nodeId, out SqlBrowseNodeRef reference)
|
||||
{
|
||||
reference = default;
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) return false;
|
||||
|
||||
var terminator = nodeId.IndexOf(KindTerminator, StringComparison.Ordinal);
|
||||
if (terminator <= 0) return false;
|
||||
|
||||
var kind = nodeId[..terminator];
|
||||
if (!TrySplit(nodeId[(terminator + 1)..], out var parts)) return false;
|
||||
if (parts.Exists(string.IsNullOrWhiteSpace)) return false;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case SchemaKind when parts.Count == 1:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Schema, parts[0], null, null);
|
||||
return true;
|
||||
case TableKind when parts.Count == 2:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Table, parts[0], parts[1], null);
|
||||
return true;
|
||||
case ColumnKind when parts.Count == 3:
|
||||
reference = new SqlBrowseNodeRef(SqlBrowseNodeKind.Column, parts[0], parts[1], parts[2]);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a NodeId, throwing when it is malformed.
|
||||
/// <para>The message deliberately does <b>not</b> echo the offending value: a NodeId can carry an
|
||||
/// arbitrary authored identifier and this text reaches the AdminUI's inline error and the log.</para>
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The NodeId to decode.</param>
|
||||
/// <returns>The decoded reference.</returns>
|
||||
/// <exception cref="ArgumentException">The value is not a well-formed SQL browse NodeId.</exception>
|
||||
public static SqlBrowseNodeRef Parse(string? nodeId)
|
||||
{
|
||||
if (TryParse(nodeId, out var reference)) return reference;
|
||||
throw new ArgumentException(
|
||||
"Not a SQL schema-browse node. Expected 'schema:<schema>', 'table:<schema>|<table>' or " +
|
||||
"'column:<schema>|<table>|<column>'. Re-open the browser and expand from the root.",
|
||||
nameof(nodeId));
|
||||
}
|
||||
|
||||
private static string Encode(string kind, params string[] parts)
|
||||
{
|
||||
var builder = new StringBuilder(kind.Length + 1 + (parts.Length * 16));
|
||||
builder.Append(kind).Append(KindTerminator);
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parts[i]))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A SQL catalog name in a browse node may not be empty or whitespace.", nameof(parts));
|
||||
}
|
||||
|
||||
if (i > 0) builder.Append(Separator);
|
||||
AppendEscaped(builder, parts[i]);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendEscaped(StringBuilder builder, string part)
|
||||
{
|
||||
foreach (var ch in part)
|
||||
{
|
||||
if (ch is Separator or Escape) builder.Append(Escape);
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits an encoded payload on <b>unescaped</b> separators, unescaping as it goes. Returns
|
||||
/// <c>false</c> on a dangling trailing escape, which cannot be produced by
|
||||
/// <see cref="AppendEscaped"/> and therefore means the value was hand-made or truncated.
|
||||
/// </summary>
|
||||
private static bool TrySplit(string payload, out List<string> parts)
|
||||
{
|
||||
parts = [];
|
||||
var current = new StringBuilder(payload.Length);
|
||||
var escaped = false;
|
||||
|
||||
foreach (var ch in payload)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
current.Append(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case Escape:
|
||||
escaped = true;
|
||||
break;
|
||||
case Separator:
|
||||
parts.Add(current.ToString());
|
||||
current.Clear();
|
||||
break;
|
||||
default:
|
||||
current.Append(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) return false;
|
||||
parts.Add(current.ToString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Live, one-level-per-call walk of a relational catalog: schemas → tables/views → columns, with the
|
||||
/// column's mapped <c>DriverDataType</c> in the attribute side-panel (design §4.1, mirroring Galaxy's
|
||||
/// two-stage object-then-attribute pick). Created by <c>SqlDriverBrowser</c> on picker open and owned by
|
||||
/// the AdminUI's <c>BrowseSessionRegistry</c>, whose TTL reaper disposes idle sessions.
|
||||
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
|
||||
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
|
||||
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
|
||||
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
|
||||
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
|
||||
/// never needs an identifier in text.</para>
|
||||
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
|
||||
/// <see cref="DisposeAsync"/>.</b> It does not open one, and it never re-opens a closed one. The handoff
|
||||
/// is the same as the Galaxy and OPC UA client sessions': the browser owns the connection only until
|
||||
/// construction succeeds, after which the registry-held session is the sole thing with a lifetime hook —
|
||||
/// if the session did not close it, nothing would, and every reaped picker would leak a pooled
|
||||
/// connection.</para>
|
||||
/// <para><b>No timeout of its own.</b> The AdminUI already bounds each root/expand/attributes call with a
|
||||
/// 20-second linked CTS (<c>BrowserSessionService.PerCallTimeout</c>); this session's job is simply to
|
||||
/// honour the token it is handed, into the gate wait and into every ADO.NET call. Adding a second
|
||||
/// independent deadline here would only produce two competing, differently-worded failures.</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlBrowseSession : IBrowseSession
|
||||
{
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
|
||||
private const string SchemaColumn = "TABLE_SCHEMA";
|
||||
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
|
||||
private const string TableNameColumn = "TABLE_NAME";
|
||||
|
||||
/// <summary>Column alias carrying <c>BASE TABLE</c> / <c>VIEW</c>.</summary>
|
||||
private const string TableTypeColumn = "TABLE_TYPE";
|
||||
|
||||
/// <summary>Column alias every dialect's <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>Column alias carrying the SQL type family name.</summary>
|
||||
private const string DataTypeColumn = "DATA_TYPE";
|
||||
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
/// <summary>
|
||||
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
|
||||
/// (design §4.3), so there is nothing else a picked column could be.
|
||||
/// </summary>
|
||||
private const string ReadOnlySecurityClass = "ViewOnly";
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly ISqlDialect _dialect;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes catalog calls. One ADO.NET connection carries at most one active command/reader, and
|
||||
/// the AdminUI tree happily fires several expands at once when an operator clicks quickly.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <summary>Constructs a session over an already-open connection, which it takes ownership of.</summary>
|
||||
/// <param name="connection">The open connection to browse. Closed by <see cref="DisposeAsync"/>.</param>
|
||||
/// <param name="dialect">The dialect supplying the catalog SQL and the column-type map.</param>
|
||||
internal SqlBrowseSession(DbConnection connection, ISqlDialect dialect)
|
||||
{
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_dialect = dialect ?? throw new ArgumentNullException(nameof(dialect));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var schemas = await QueryAsync(
|
||||
_dialect.ListSchemasSql,
|
||||
static _ => { },
|
||||
static reader => ReadString(reader, SchemaColumn),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(schemas.Count);
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schema)) continue;
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForSchema(schema),
|
||||
DisplayName: schema,
|
||||
Kind: BrowseNodeKind.Folder,
|
||||
HasChildrenHint: true));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
|
||||
public async Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var reference = SqlBrowseNodeId.Parse(nodeId);
|
||||
switch (reference.Kind)
|
||||
{
|
||||
case SqlBrowseNodeKind.Schema:
|
||||
return await ExpandSchemaAsync(reference.Schema, cancellationToken).ConfigureAwait(false);
|
||||
case SqlBrowseNodeKind.Table:
|
||||
return await ExpandTableAsync(reference.Schema, reference.Table!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
default:
|
||||
// A column is terminal. Expanding one is a UI no-op, never an error — the tree may ask before
|
||||
// it has re-read the node's Kind.
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<BrowseNode>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="ArgumentException"><paramref name="nodeId"/> is not a SQL schema-browse node.</exception>
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(
|
||||
string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var reference = SqlBrowseNodeId.Parse(nodeId);
|
||||
if (reference.Kind != SqlBrowseNodeKind.Column)
|
||||
{
|
||||
// Schemas and tables have no side-panel — same shape as the OPC UA client browser, whose tree is
|
||||
// uniform and returns empty for every node.
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<AttributeInfo>();
|
||||
}
|
||||
|
||||
var columns = await ReadColumnsAsync(
|
||||
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
|
||||
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
|
||||
// that legitimately carries both "Value" and "value".
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
|
||||
return
|
||||
[
|
||||
new AttributeInfo(
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass),
|
||||
];
|
||||
}
|
||||
|
||||
// The column is gone (dropped since the expand, or the NodeId was hand-made). An empty side-panel is
|
||||
// the honest answer; the picker simply has nothing to prefill.
|
||||
return Array.Empty<AttributeInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
|
||||
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { await _connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort: the connection may already be broken or closed. */ }
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
|
||||
{
|
||||
var rows = await QueryAsync(
|
||||
_dialect.ListTablesSql,
|
||||
command => Bind(command, "@schema", schema),
|
||||
static reader => (
|
||||
Name: ReadString(reader, TableNameColumn),
|
||||
Type: ReadOptionalString(reader, TableTypeColumn)),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(rows.Count);
|
||||
foreach (var (name, type) in rows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
var isView = string.Equals(type, ViewTableType, StringComparison.OrdinalIgnoreCase);
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForTable(schema, name),
|
||||
// The label is decorated, never the NodeId — a view and a table of the same name in the same
|
||||
// schema cannot exist, so the suffix is presentation only.
|
||||
DisplayName: isView ? $"{name} (view)" : name,
|
||||
Kind: BrowseNodeKind.Folder,
|
||||
HasChildrenHint: true));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BrowseNode>> ExpandTableAsync(
|
||||
string schema, string table, CancellationToken ct)
|
||||
{
|
||||
var columns = await ReadColumnsAsync(schema, table, ct).ConfigureAwait(false);
|
||||
|
||||
var nodes = new List<BrowseNode>(columns.Count);
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(column.Name)) continue;
|
||||
nodes.Add(new BrowseNode(
|
||||
NodeId: SqlBrowseNodeId.ForColumn(schema, table, column.Name),
|
||||
DisplayName: column.Name,
|
||||
Kind: BrowseNodeKind.Leaf,
|
||||
HasChildrenHint: false));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
|
||||
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
|
||||
/// relation, and neither is a reason to fail an operator's click.
|
||||
/// </summary>
|
||||
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
|
||||
string schema, string table, CancellationToken ct) =>
|
||||
QueryAsync(
|
||||
_dialect.ListColumnsSql,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
|
||||
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
|
||||
/// </summary>
|
||||
private async Task<List<T>> QueryAsync<T>(
|
||||
string sql,
|
||||
Action<DbCommand> bind,
|
||||
Func<DbDataReader, T> project,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var results = new List<T>();
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
bind(command);
|
||||
|
||||
await using var reader = await command
|
||||
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
results.Add(project(reader));
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
|
||||
// ignore and must not mask the real result.
|
||||
try { _gate.Release(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Binds one catalog-query parameter. The only way a name reaches the database.</summary>
|
||||
private static void Bind(DbCommand command, string name, string value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
private static string ReadString(DbDataReader reader, string columnName) =>
|
||||
ReadOptionalString(reader, columnName) ?? string.Empty;
|
||||
|
||||
private static string? ReadOptionalString(DbDataReader reader, string columnName)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(columnName);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a <b>transient</b> database connection from form-supplied JSON for the AdminUI schema picker,
|
||||
/// and hands it to a <see cref="SqlBrowseSession"/>. The AdminUI's <c>BrowseSessionRegistry</c> (its idle
|
||||
/// TTL reaper included) owns the returned session unchanged — nothing about the SQL browse needs a
|
||||
/// registry of its own.
|
||||
/// <para><b>Two ways to name the database, and only one of them is persisted.</b>
|
||||
/// <c>connectionStringRef</c> is the deployed form: a name resolved <em>in the AdminUI process</em> from
|
||||
/// the environment as <c>Sql__ConnectionStrings__<ref></c> (design §4.4), so a deployed artifact
|
||||
/// never carries credentials. <c>connectionString</c> is an ad-hoc, <b>session-only</b> literal an
|
||||
/// operator may paste to browse a database that has no ref yet; it is used for this one connection and
|
||||
/// then forgotten — there is deliberately no cached-config field on this type, and nothing writes it
|
||||
/// anywhere.</para>
|
||||
/// <para><b>Precedence: a pasted literal wins over a ref</b>, and the ref is then not even read. The
|
||||
/// literal cannot arrive from a persisted blob — <see cref="SqlDriverConfigDto"/> has no such property, so
|
||||
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
|
||||
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
|
||||
/// the override is visible rather than silent.</para>
|
||||
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
|
||||
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
|
||||
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their
|
||||
/// own errors, every failure on the open path goes through <see cref="Sanitize"/> before it leaves this
|
||||
/// class.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverBrowser : IDriverBrowser
|
||||
{
|
||||
/// <summary>
|
||||
/// Prefix of the environment variable a <c>connectionStringRef</c> resolves through — the
|
||||
/// double-underscore form .NET configuration uses for the <c>Sql:ConnectionStrings:<ref></c>
|
||||
/// key path.
|
||||
/// </summary>
|
||||
public const string ConnectionStringEnvironmentPrefix = "Sql__ConnectionStrings__";
|
||||
|
||||
/// <summary>
|
||||
/// Hard cap on the connect phase, layered on the caller's token. The AdminUI already bounds each
|
||||
/// browse call at 20 s; this only stops a pathological provider-side connect from outliving the
|
||||
/// picker entirely.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ConnectBudget = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Connection-string keys whose <em>values</em> are redacted out of any provider error text. The
|
||||
/// whole connection string is redacted unconditionally; these are the parts that could survive as a
|
||||
/// fragment. Server / database names are deliberately <b>not</b> redacted — "server X was not found"
|
||||
/// is the diagnostic the operator needs, and a hostname is not a credential.
|
||||
/// </summary>
|
||||
private static readonly string[] CredentialKeys =
|
||||
[
|
||||
"password", "pwd", "user id", "uid", "user", "username", "accesstoken", "access token",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Kept deliberately identical in shape to the driver factory's options (design §5.1): enum-valued
|
||||
/// knobs are authored as their <em>names</em>, so the browser parses a given <c>DriverConfig</c> blob
|
||||
/// exactly as the runtime factory does. <see cref="JsonStringEnumConverter"/> also accepts ordinals,
|
||||
/// so a numeric config still binds.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly Func<SqlProvider, ISqlDialect?> _dialectSelector;
|
||||
private readonly Func<string, string?> _environmentReader;
|
||||
private readonly ILogger<SqlDriverBrowser> _logger;
|
||||
|
||||
/// <summary>Creates a browser. Every dependency has a production default, so DI may construct it bare.</summary>
|
||||
/// <param name="dialectSelector">
|
||||
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
|
||||
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
|
||||
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
|
||||
/// provider gets added without touching this class.
|
||||
/// </param>
|
||||
/// <param name="environmentReader">
|
||||
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
|
||||
/// Injectable because environment variables are process-global, and a test that sets one races every
|
||||
/// other test in the assembly.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
public SqlDriverBrowser(
|
||||
Func<SqlProvider, ISqlDialect?>? dialectSelector = null,
|
||||
Func<string, string?>? environmentReader = null,
|
||||
ILogger<SqlDriverBrowser>? logger = null)
|
||||
{
|
||||
_dialectSelector = dialectSelector ?? DefaultDialectSelector;
|
||||
_environmentReader = environmentReader ?? Environment.GetEnvironmentVariable;
|
||||
_logger = logger ?? NullLogger<SqlDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
|
||||
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
|
||||
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
|
||||
/// registered against it. Repointing that one constant repoints this too.
|
||||
/// </remarks>
|
||||
public string DriverType => SqlDriver.DriverTypeName;
|
||||
|
||||
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The full environment-variable name, e.g. <c>Sql__ConnectionStrings__MesStaging</c>.</returns>
|
||||
public static string EnvironmentVariableFor(string connectionStringRef) =>
|
||||
ConnectionStringEnvironmentPrefix + connectionStringRef;
|
||||
|
||||
/// <summary>
|
||||
/// The providers this build can browse. v1 constructs SQL Server only; every other
|
||||
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
|
||||
/// the caller reports it as unavailable rather than crashing on a null dialect.
|
||||
/// </summary>
|
||||
/// <param name="provider">The authored provider.</param>
|
||||
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
|
||||
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
|
||||
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The configuration is absent / malformed, names a provider this build does not carry, names neither
|
||||
/// a <c>connectionStringRef</c> nor a literal, resolves a ref that is not set in the environment, or
|
||||
/// the connection could not be opened.
|
||||
/// </exception>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser requires a driver configuration; none was supplied.");
|
||||
}
|
||||
|
||||
var literal = ReadPastedConnectionString(configJson);
|
||||
var config = Deserialize(configJson, literal);
|
||||
|
||||
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
|
||||
// named, and answering it before touching the environment keeps the two failures from masking.
|
||||
var dialect = _dialectSelector(config.Provider)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
|
||||
+ $"'{SqlProvider.SqlServer}' only.");
|
||||
|
||||
var reference = config.ConnectionStringRef;
|
||||
var connectionString = ResolveConnectionString(literal, reference);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI Sql browse session opening (provider {Provider}, connection from {Source})",
|
||||
config.Provider,
|
||||
DescribeSource(literal, reference));
|
||||
|
||||
return await OpenSessionAsync(dialect, connectionString, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens one transient connection and hands ownership to the session. <b>The session closes the
|
||||
/// connection on its own disposal</b>, so the only disposal this method owns is the failure path —
|
||||
/// disposing on success would double-close and leave the picker with a dead session.
|
||||
/// </summary>
|
||||
private static async Task<IBrowseSession> OpenSessionAsync(
|
||||
ISqlDialect dialect, string connectionString, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = dialect.Factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"The ADO.NET provider for '{dialect.Provider}' returned no connection object.");
|
||||
|
||||
try
|
||||
{
|
||||
connection.ConnectionString = connectionString;
|
||||
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(ConnectBudget);
|
||||
await connection.OpenAsync(connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
return new SqlBrowseSession(connection, dialect);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { await connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort: the original failure is the useful one. */ }
|
||||
|
||||
if (ex is OperationCanceledException) throw;
|
||||
throw Sanitize(ex, connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the connection string, literal first. A blank literal is treated as absent (an untouched
|
||||
/// form field), never as an empty connection string.
|
||||
/// </summary>
|
||||
private string ResolveConnectionString(string? literal, string? reference)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(literal))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql browse: a pasted connection string was supplied alongside connectionStringRef "
|
||||
+ "'{Ref}'. The pasted value wins for this session only and is not persisted.",
|
||||
reference);
|
||||
}
|
||||
|
||||
return literal;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser needs a database to browse: set 'connectionStringRef' to a name resolved "
|
||||
+ $"from the environment as '{ConnectionStringEnvironmentPrefix}<ref>', or paste a "
|
||||
+ "connection string into 'connectionString' for an ad-hoc browse.");
|
||||
}
|
||||
|
||||
var variable = EnvironmentVariableFor(reference);
|
||||
var resolved = _environmentReader(variable);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
// Name the exact variable: "the ref did not resolve" is unactionable, and the operator's next
|
||||
// move is to set this one name on the AdminUI host.
|
||||
throw new InvalidOperationException(
|
||||
$"connectionStringRef '{reference}' does not resolve: environment variable "
|
||||
+ $"'{variable}' is not set on the AdminUI host. Set it there (the AdminUI resolves browse "
|
||||
+ "connection strings in its own process), or paste a connection string for an ad-hoc "
|
||||
+ "browse.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>How the connection string was obtained, for the log. Carries no connection text.</summary>
|
||||
private static string DescribeSource(string? literal, string? reference) =>
|
||||
!string.IsNullOrWhiteSpace(literal)
|
||||
? "a pasted literal (session-only, not persisted)"
|
||||
: $"connectionStringRef '{reference}'";
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the ad-hoc <c>connectionString</c> literal out of the raw JSON. It is read here rather than
|
||||
/// off the DTO because <see cref="SqlDriverConfigDto"/> deliberately has no such property — the
|
||||
/// persisted contract must not be able to carry a credential — and the DTO's
|
||||
/// <see cref="JsonUnmappedMemberHandling.Skip"/> would silently drop it.
|
||||
/// </summary>
|
||||
/// <returns>The literal, or <see langword="null"/> when absent, blank, or not a JSON string.</returns>
|
||||
private static string? ReadPastedConnectionString(string configJson)
|
||||
{
|
||||
using var document = ParseDocument(configJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration must be a JSON object.");
|
||||
}
|
||||
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(property.Name, "connectionString", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (property.Value.ValueKind != JsonValueKind.String) return null;
|
||||
var value = property.Value.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the raw configuration. The <see cref="JsonException"/> is <b>not</b> attached or echoed:
|
||||
/// at this point nothing has been extracted, so there is no known secret to redact against, and the
|
||||
/// malformed text may itself be a pasted connection string.
|
||||
/// </summary>
|
||||
private static JsonDocument ParseDocument(string configJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(configJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration is not valid JSON. (The parser's message is "
|
||||
+ "withheld because the malformed text may carry a connection string.)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the typed configuration. A bind failure (e.g. an unknown <c>provider</c> name) is reported
|
||||
/// with the parser's own message redacted against the literal, which is known by now.
|
||||
/// </summary>
|
||||
private static SqlDriverConfigDto Deserialize(string configJson, string? literal)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SqlDriverConfigDto>(configJson, JsonOpts)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration deserialized to null.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The Sql browser's driver configuration could not be bound: "
|
||||
+ Redact(ex.Message, CollectSecrets(literal)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a provider-side open failure into an exception that cannot carry the connection string.
|
||||
/// <para><b>Live-probed, not assumed.</b> <c>Microsoft.Data.SqlClient</c> and
|
||||
/// <c>Microsoft.Data.Sqlite</c> keep connection-string <em>values</em> out of their
|
||||
/// <c>SqlException</c>/<c>SqliteException</c> text (a failed connect says "the server was not found",
|
||||
/// never what it was handed). Their connection-string <b>parser</b> is the exception: it reports an
|
||||
/// unrecognised keyword by quoting it, and a value carrying an unquoted <c>;</c> — legal inside a
|
||||
/// password, which ADO.NET expects you to quote — splits, so the tail of the password is parsed as a
|
||||
/// keyword and echoed verbatim. <c>Password=Sup3r;SecretTail</c> yields
|
||||
/// <c>Keyword not supported: 'secrettail;connect timeout'.</c> — the credential, in the message,
|
||||
/// lower-cased and therefore invisible to a naive ordinal search for the value.</para>
|
||||
/// <para>So the parser's message (an <see cref="ArgumentException"/>) is <b>never</b> surfaced. Every
|
||||
/// other failure is additionally passed through the substring redactor as a backstop, and when that
|
||||
/// fires anywhere in the exception <em>chain</em> the inner exception is dropped too — keeping it
|
||||
/// would re-expose through <c>ToString()</c> exactly what the message just removed.</para>
|
||||
/// </summary>
|
||||
private static InvalidOperationException Sanitize(Exception ex, string connectionString)
|
||||
{
|
||||
if (ex is ArgumentException)
|
||||
{
|
||||
return new InvalidOperationException(
|
||||
"The Sql browser could not open a connection: the connection string is malformed, or "
|
||||
+ "carries a keyword this provider does not support. (The provider's own message is "
|
||||
+ "withheld because it quotes connection-string text verbatim; check for an unquoted ';' "
|
||||
+ "or '=' inside a value such as the password.)");
|
||||
}
|
||||
|
||||
var secrets = CollectSecrets(connectionString);
|
||||
var leaked = ContainsAny(ex.ToString(), secrets);
|
||||
var reason = Redact(ex.Message, secrets);
|
||||
|
||||
var message = leaked
|
||||
? "The Sql browser could not open a connection: " + reason
|
||||
+ " (the provider's error echoed the connection string, so it was redacted and the inner "
|
||||
+ "exception withheld)"
|
||||
: "The Sql browser could not open a connection: " + reason;
|
||||
|
||||
return new InvalidOperationException(message, leaked ? null : ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The substrings that must never leave this class: the whole connection string, and the values of
|
||||
/// its credential-bearing keys (which could surface on their own).
|
||||
/// </summary>
|
||||
private static List<string> CollectSecrets(string? connectionString)
|
||||
{
|
||||
var secrets = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(connectionString)) return secrets;
|
||||
|
||||
secrets.Add(connectionString);
|
||||
|
||||
try
|
||||
{
|
||||
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
|
||||
foreach (var key in CredentialKeys)
|
||||
{
|
||||
if (!builder.TryGetValue(key, out var value)) continue;
|
||||
var text = value?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(text)) secrets.Add(text);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A malformed connection string has no parseable parts; the whole-string entry still stands.
|
||||
}
|
||||
|
||||
// Longest first, so redacting a short credential value cannot chop a longer secret into
|
||||
// unredactable halves.
|
||||
secrets.Sort(static (left, right) => right.Length.CompareTo(left.Length));
|
||||
return secrets;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, List<string> secrets)
|
||||
{
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
if (text.Contains(secret, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Redact(string text, List<string> secrets)
|
||||
{
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
text = text.Replace(secret, "[redacted]", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
|
||||
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
|
||||
Contracts project and owns its own transport packages): the dialect's catalog SQL
|
||||
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
|
||||
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
|
||||
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
|
||||
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
|
||||
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
|
||||
Contracts-only reference without reading that context first. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>DriverConfig</c> blob shape for a <c>Sql</c> driver instance (design §5.1). Deserialised by the
|
||||
/// driver factory, which applies the documented defaults for every absent (null) field and maps the result
|
||||
/// onto the driver's typed options.
|
||||
/// <para><b>No connection string lives here.</b> Only <see cref="ConnectionStringRef"/> — a name resolved at
|
||||
/// Initialize from the environment / secret store (e.g. <c>Sql__ConnectionStrings__MesStaging</c>) — so a
|
||||
/// deployed artifact never carries database credentials.</para>
|
||||
/// <para>Enum-valued fields are enum-typed (not strings) so a <c>JsonStringEnumConverter</c> round-trips
|
||||
/// their <em>names</em> rather than their ordinals. The converter is applied by the factory's
|
||||
/// <c>JsonSerializerOptions</c> (Task 9), not by an attribute on this DTO — mirroring the driver
|
||||
/// enum-serialization trap that bit the AdminUI driver pages.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverConfigDto
|
||||
{
|
||||
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
|
||||
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the connection string to resolve from the environment / secret store at Initialize —
|
||||
/// <b>never</b> the connection string itself.
|
||||
/// </summary>
|
||||
public string? ConnectionStringRef { get; init; }
|
||||
|
||||
/// <summary>Poll interval applied to groups that do not carry their own. Absent ⇒ the factory default.</summary>
|
||||
public TimeSpan? DefaultPollInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-query wall-clock deadline enforced client-side (a breach surfaces <c>BadTimeout</c>). Must be
|
||||
/// greater than <see cref="CommandTimeout"/>, which is only the server-side backstop (design §8.3).
|
||||
/// </summary>
|
||||
public TimeSpan? OperationTimeout { get; init; }
|
||||
|
||||
/// <summary>ADO.NET <c>CommandTimeout</c> backstop (seconds granularity server-side).</summary>
|
||||
public TimeSpan? CommandTimeout { get; init; }
|
||||
|
||||
/// <summary>Cap on concurrently executing group queries — the connection-pool guard.</summary>
|
||||
public int? MaxConcurrentGroups { get; init; }
|
||||
|
||||
/// <summary>When <see langword="true"/>, a NULL cell publishes Bad rather than the default Uncertain.</summary>
|
||||
public bool? NullIsBad { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Master write kill-switch. <b>v1 is read-only and this field is inert</b> — the driver does not
|
||||
/// implement <c>IWritable</c>, so no value here can produce a write. The factory <em>warns</em> when it
|
||||
/// is authored <see langword="true"/> rather than failing the driver, since the flag cannot do harm but
|
||||
/// an operator who believes writes are enabled can.
|
||||
/// </summary>
|
||||
public bool? AllowWrites { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The authored raw tags the deploy artifact delivers for this driver instance — RawPath identity plus
|
||||
/// the driver <c>TagConfig</c> blob, the same shape every other driver receives. The factory copies
|
||||
/// these onto the driver's options and the driver maps each through
|
||||
/// <see cref="SqlEquipmentTagParser.TryParse"/> at Initialize.
|
||||
/// <para>A SQL source has no tag-discovery protocol, so this list is the complete set of tags the
|
||||
/// instance serves — an absent or empty list is a driver with nothing to poll, not a driver that will
|
||||
/// find its tags later.</para>
|
||||
/// </summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper from an authored raw tag's <c>TagConfig</c> JSON (design §5.2 / §5.3) to a
|
||||
/// <see cref="SqlTagDefinition"/>, mirroring <c>ModbusTagDefinitionFactory.FromTagConfig</c>. The blob is
|
||||
/// recognised by a leading <c>{</c> plus a <c>"driver":"Sql"</c> marker or a <c>"model"</c> discriminator;
|
||||
/// the <c>model</c> and <c>type</c> enums are read with
|
||||
/// <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>, so a present-but-invalid (typo'd) value rejects
|
||||
/// the whole tag — the driver then surfaces <c>BadNodeIdUnknown</c> instead of silently defaulting to a
|
||||
/// different model and publishing a misleading <c>Good</c> (R2-11).
|
||||
/// <para><b>No SQL is built here.</b> The parser only captures strings; the reader binds value fields as
|
||||
/// <c>DbParameter</c>s and validates + quotes identifier fields. Tag input is never concatenated into a
|
||||
/// command text, so a hostile <c>keyValue</c> is stored — and must be stored — verbatim.</para>
|
||||
/// </summary>
|
||||
public static class SqlEquipmentTagParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> blob to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// Returns <see langword="false"/> — never throws — for anything the driver must not serve: a
|
||||
/// non-object / unparseable blob, a blob belonging to another driver, a typo'd <c>model</c> or
|
||||
/// <c>type</c> enum, a missing model-required field, or the P3-deferred
|
||||
/// <see cref="SqlTagModel.Query"/> model.
|
||||
/// </summary>
|
||||
/// <param name="reference">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid Sql tag blob.</returns>
|
||||
public static bool TryParse(string reference, string rawPath, out SqlTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(reference)) return false;
|
||||
if (reference.TrimStart().FirstOrDefault() != '{') return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
// Recognition: either the explicit driver marker or the model discriminator must be present,
|
||||
// so another driver's TagConfig blob is not mistaken for a Sql one.
|
||||
var driver = ReadString(root, "driver");
|
||||
var hasModel = root.TryGetProperty("model", out _);
|
||||
if (!hasModel && !string.Equals(driver, "Sql", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
// Strict enum reads: absent ⇒ the fallback, present-but-invalid ⇒ reject the tag.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "model", SqlTagModel.KeyValue, out var model)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "type", default(DriverDataType), out var type)) return false;
|
||||
DriverDataType? declaredType =
|
||||
root.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String
|
||||
? type
|
||||
: null;
|
||||
|
||||
var table = ReadString(root, "table");
|
||||
if (string.IsNullOrWhiteSpace(table)) return false;
|
||||
var timestampColumn = ReadString(root, "timestampColumn");
|
||||
|
||||
switch (model)
|
||||
{
|
||||
case SqlTagModel.KeyValue:
|
||||
{
|
||||
var keyColumn = ReadString(root, "keyColumn");
|
||||
var keyValue = ReadString(root, "keyValue");
|
||||
var valueColumn = ReadString(root, "valueColumn");
|
||||
if (string.IsNullOrWhiteSpace(keyColumn)
|
||||
|| string.IsNullOrWhiteSpace(valueColumn)
|
||||
|| keyValue is null)
|
||||
return false;
|
||||
def = new SqlTagDefinition(
|
||||
Name: rawPath, Model: model, Table: table!,
|
||||
KeyColumn: keyColumn, KeyValue: keyValue, ValueColumn: valueColumn,
|
||||
TimestampColumn: timestampColumn, DeclaredType: declaredType);
|
||||
return true;
|
||||
}
|
||||
|
||||
case SqlTagModel.WideRow:
|
||||
{
|
||||
var columnName = ReadString(root, "columnName");
|
||||
if (string.IsNullOrWhiteSpace(columnName)) return false;
|
||||
string? selectorColumn = null, selectorValue = null, topByTimestamp = null;
|
||||
if (root.TryGetProperty("rowSelector", out var sel) && sel.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
selectorColumn = ReadString(sel, "whereColumn");
|
||||
selectorValue = ReadScalar(sel, "whereValue");
|
||||
topByTimestamp = ReadString(sel, "topByTimestamp");
|
||||
}
|
||||
// A wide-row tag must say WHICH row it reads — either a where-pair or newest-by-timestamp.
|
||||
var hasWherePair = !string.IsNullOrWhiteSpace(selectorColumn) && selectorValue is not null;
|
||||
if (!hasWherePair && string.IsNullOrWhiteSpace(topByTimestamp)) return false;
|
||||
def = new SqlTagDefinition(
|
||||
Name: rawPath, Model: model, Table: table!,
|
||||
TimestampColumn: timestampColumn, ColumnName: columnName,
|
||||
RowSelectorColumn: hasWherePair ? selectorColumn : null,
|
||||
RowSelectorValue: hasWherePair ? selectorValue : null,
|
||||
RowSelectorTopByTimestamp: hasWherePair ? null : topByTimestamp,
|
||||
DeclaredType: declaredType);
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
// SqlTagModel.Query is design §5.4 / P3 — deferred. Reject rather than serve a model
|
||||
// the runtime cannot read.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Reads a string-valued property; null when absent or not a JSON string.</summary>
|
||||
private static string? ReadString(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a scalar property as its textual form — a JSON string yields its contents, a number or
|
||||
/// boolean yields its raw token — so an authored <c>whereValue</c> of either shape is captured
|
||||
/// verbatim for later parameter binding. Null when absent or not a scalar.
|
||||
/// </summary>
|
||||
private static string? ReadScalar(JsonElement o, string name)
|
||||
{
|
||||
if (!o.TryGetProperty(name, out var e)) return null;
|
||||
return e.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => e.GetString(),
|
||||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => e.GetRawText(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
public enum SqlProvider
|
||||
{
|
||||
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
|
||||
SqlServer,
|
||||
|
||||
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
|
||||
MySql,
|
||||
|
||||
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
|
||||
Odbc,
|
||||
|
||||
/// <summary>Oracle — reserved; not constructed in v1.</summary>
|
||||
Oracle,
|
||||
}
|
||||
|
||||
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
|
||||
public enum SqlTagModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Key-value (EAV) table: one row per tag, selected by <c>keyColumn = keyValue</c>, value read from
|
||||
/// <c>valueColumn</c>.
|
||||
/// </summary>
|
||||
KeyValue,
|
||||
|
||||
/// <summary>
|
||||
/// Wide row: one row holds many signals as columns; the tag names its <c>columnName</c> and the row is
|
||||
/// picked by a row selector (a <c>whereColumn</c>/<c>whereValue</c> pair, or newest-by-timestamp).
|
||||
/// </summary>
|
||||
WideRow,
|
||||
|
||||
/// <summary>Named arbitrary-SELECT query (design §5.4). <b>Deferred to P3 — not accepted by v1's parser.</b></summary>
|
||||
Query,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// One SQL-backed OPC UA variable — the typed form of an authored raw tag's <c>TagConfig</c> blob
|
||||
/// (design §5.2 / §5.3), produced by <see cref="SqlEquipmentTagParser.TryParse"/>.
|
||||
/// <para><b>Every string here is captured verbatim from the authored blob and is NEVER concatenated
|
||||
/// into SQL.</b> Value-bearing fields (<see cref="KeyValue"/>, <see cref="RowSelectorValue"/>) are bound
|
||||
/// as <c>DbParameter</c>s by the reader; identifier-bearing fields (<see cref="Table"/>,
|
||||
/// <see cref="KeyColumn"/>, <see cref="ValueColumn"/>, <see cref="TimestampColumn"/>,
|
||||
/// <see cref="ColumnName"/>, <see cref="RowSelectorColumn"/>,
|
||||
/// <see cref="RowSelectorTopByTimestamp"/>) must be validated against <c>INFORMATION_SCHEMA</c> and
|
||||
/// dialect-quoted by the reader before they reach a command text. Sanitising here would corrupt
|
||||
/// legitimate values, so the parser deliberately does not.</para>
|
||||
/// </summary>
|
||||
/// <param name="Name">
|
||||
/// The tag's identity — its <b>RawPath</b>, exactly as handed to the parser. The driver's forward
|
||||
/// router keys published values on this, so it must never be derived from the blob's contents.
|
||||
/// </param>
|
||||
/// <param name="Model">Which tag→value mapping this tag uses. v1 accepts KeyValue and WideRow only.</param>
|
||||
/// <param name="Table">The table or view to read (e.g. <c>dbo.TagValues</c>).</param>
|
||||
/// <param name="KeyColumn"><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</param>
|
||||
/// <param name="KeyValue"><see cref="SqlTagModel.KeyValue"/>: this tag's key — <b>bound</b>, never interpolated.</param>
|
||||
/// <param name="ValueColumn"><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</param>
|
||||
/// <param name="TimestampColumn">Optional source-timestamp column; absent ⇒ the driver stamps the poll time.</param>
|
||||
/// <param name="ColumnName"><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</param>
|
||||
/// <param name="RowSelectorColumn"><see cref="SqlTagModel.WideRow"/>: the <c>whereColumn</c> that picks the row.</param>
|
||||
/// <param name="RowSelectorValue"><see cref="SqlTagModel.WideRow"/>: the <c>whereValue</c> — <b>bound</b>, never interpolated.</param>
|
||||
/// <param name="RowSelectorTopByTimestamp"><see cref="SqlTagModel.WideRow"/>: newest-row selector — the timestamp column to order by, when no where-pair is authored.</param>
|
||||
/// <param name="DeclaredType">
|
||||
/// Optional explicit type override from the blob's <c>type</c> field. <see langword="null"/> ⇒ the
|
||||
/// driver infers the type from the result set's column metadata.
|
||||
/// </param>
|
||||
public sealed record SqlTagDefinition(
|
||||
string Name,
|
||||
SqlTagModel Model,
|
||||
string Table,
|
||||
string? KeyColumn = null,
|
||||
string? KeyValue = null,
|
||||
string? ValueColumn = null,
|
||||
string? TimestampColumn = null,
|
||||
string? ColumnName = null,
|
||||
string? RowSelectorColumn = null,
|
||||
string? RowSelectorValue = null,
|
||||
string? RowSelectorTopByTimestamp = null,
|
||||
DriverDataType? DeclaredType = null);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
|
||||
/// connections, commands, parameters and readers; they do <b>not</b> abstract the three things that differ
|
||||
/// per backend — <b>identifier quoting</b>, the <b>metadata-catalog SQL</b>, and <b>row-limit syntax</b>.
|
||||
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
|
||||
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
|
||||
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
|
||||
/// parameterized in SQL, so the few that must appear as text are emitted through
|
||||
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
|
||||
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
|
||||
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
|
||||
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
|
||||
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
|
||||
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
|
||||
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
|
||||
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
|
||||
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
|
||||
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
|
||||
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
|
||||
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
|
||||
/// <see cref="DbProviderFactory"/>).</para>
|
||||
/// </summary>
|
||||
public interface ISqlDialect
|
||||
{
|
||||
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
|
||||
SqlProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The provider's factory singleton, used to create connections/commands/parameters. Deliberately the
|
||||
/// abstract base type so consumers never bind to a concrete provider package.
|
||||
/// </summary>
|
||||
DbProviderFactory Factory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Quotes <b>one</b> identifier part so it can be safely embedded in a command text, escaping the
|
||||
/// dialect's own quote character. Rejects anything that cannot be a real catalog identifier rather
|
||||
/// than emitting it.
|
||||
/// <para><b>One part only.</b> A multi-part name such as <c>dbo.TagValues</c> must be split by the
|
||||
/// caller and each part quoted separately; passing the dotted form yields a single (nonexistent)
|
||||
/// identifier — safe, but not what the caller meant.</para>
|
||||
/// </summary>
|
||||
/// <param name="ident">The bare, unquoted identifier — as returned by the catalog, not pre-quoted.</param>
|
||||
/// <returns>The quoted identifier, ready to concatenate into a command text.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
|
||||
string QuoteIdentifier(string ident);
|
||||
|
||||
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||
string LivenessSql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fragment that goes <b>immediately after <c>SELECT</c></b> to limit a statement to one row —
|
||||
/// T-SQL's <c>"TOP 1 "</c>. Empty for a dialect that spells the limit at the end of the statement (see
|
||||
/// <see cref="SingleRowLimitSuffix"/>).
|
||||
/// <para><b>Include the trailing space</b> when non-empty: the planner concatenates
|
||||
/// <c>"SELECT " + SingleRowLimitPrefix + columns</c> with no separator of its own, so an empty fragment
|
||||
/// must leave the statement byte-identical to an unlimited one.</para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why a prefix/suffix pair rather than one <c>ApplyRowLimit(sql, n)</c> method.</b> The two
|
||||
/// spellings sit at opposite ends of the statement — SQL Server writes
|
||||
/// <c>SELECT TOP 1 … ORDER BY … DESC</c>, while SQLite/Postgres/MySQL write
|
||||
/// <c>SELECT … ORDER BY … DESC LIMIT 1</c>. A single method taking the SELECT list could only serve the
|
||||
/// prefix position; a single method taking the whole statement would move statement assembly out of the
|
||||
/// planner and into every dialect. Two fragments keep the planner the sole author of statement shape
|
||||
/// and let each dialect fill in the end it uses.</para>
|
||||
/// <para><b>Why "single row" rather than a row count.</b> The only limit v1 emits is the wide-row
|
||||
/// <c>topByTimestamp</c> selector's newest-row pick. Modelling an arbitrary <c>n</c> would be untested
|
||||
/// surface, and <c>FETCH FIRST</c>/<c>ROWNUM</c> dialects need more than a substituted number anyway.
|
||||
/// Widen this to a method when a feature actually needs <c>n > 1</c>.</para>
|
||||
/// </remarks>
|
||||
string SingleRowLimitPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fragment appended to the <b>very end</b> of a statement to limit it to one row —
|
||||
/// <c>" LIMIT 1"</c> for SQLite/Postgres/MySQL. Empty for a dialect that limits after <c>SELECT</c>
|
||||
/// (see <see cref="SingleRowLimitPrefix"/>).
|
||||
/// <para><b>Include the leading space</b> when non-empty, for the same reason the prefix carries a
|
||||
/// trailing one: the planner appends it directly to the finished statement.</para>
|
||||
/// <para>Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
|
||||
/// emits <b>both</b> unconditionally — a dialect needing both ends (or neither) is expressible without
|
||||
/// touching the call site.</para>
|
||||
/// </summary>
|
||||
string SingleRowLimitSuffix { get; }
|
||||
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
|
||||
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
|
||||
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
|
||||
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
|
||||
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
|
||||
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
|
||||
/// — the same dialect resolves differently for two different logins.</para>
|
||||
/// </remarks>
|
||||
string DefaultSchemaSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||
string ListTablesSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
|
||||
string ListColumnsSql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Folds a catalog data-type family name (as <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> reports it —
|
||||
/// e.g. <c>nvarchar</c>, never <c>nvarchar(50)</c>) onto a <see cref="DriverDataType"/>.
|
||||
/// <para>Must <b>never throw</b>: a browse over a table holding one exotic column must still render.
|
||||
/// An unrecognised family falls back to <see cref="DriverDataType.String"/>.</para>
|
||||
/// </summary>
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
DriverDataType MapColumnType(string sqlDataType);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
|
||||
/// allow-list design §8.1 requires identifiers to come from.
|
||||
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
|
||||
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
|
||||
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
|
||||
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
|
||||
/// identifier text in an emitted query is a string the database gave us.</para>
|
||||
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
|
||||
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
|
||||
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
|
||||
/// asks.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<string> _schemas;
|
||||
|
||||
/// <summary>Canonical schema → canonical table names in it.</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
|
||||
|
||||
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
|
||||
|
||||
/// <summary>Constructs a catalog snapshot.</summary>
|
||||
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
|
||||
/// <param name="schemas">Every schema the connection can see.</param>
|
||||
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
|
||||
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
|
||||
internal SqlCatalog(
|
||||
string defaultSchema,
|
||||
IReadOnlyList<string> schemas,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
|
||||
{
|
||||
DefaultSchema = defaultSchema;
|
||||
_schemas = schemas;
|
||||
_tablesBySchema = tablesBySchema;
|
||||
_columnsByTable = columnsByTable;
|
||||
}
|
||||
|
||||
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
|
||||
public string DefaultSchema { get; }
|
||||
|
||||
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
|
||||
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
|
||||
public bool TryResolveSchema(string? authored, out string canonical)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored))
|
||||
{
|
||||
canonical = DefaultSchema;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryMatch(_schemas, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
|
||||
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
|
||||
/// <param name="authored">The authored table or view name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
|
||||
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
|
||||
&& TryMatch(tables, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>Resolves an authored column name within a canonical relation.</summary>
|
||||
/// <param name="canonicalSchema">The resolved schema.</param>
|
||||
/// <param name="canonicalTable">The resolved table or view.</param>
|
||||
/// <param name="authored">The authored column name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
|
||||
public bool TryResolveColumn(
|
||||
string canonicalSchema, string canonicalTable, string authored, out string canonical)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
|
||||
&& TryMatch(columns, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>The dictionary key for a canonical relation.</summary>
|
||||
/// <param name="schema">The canonical schema.</param>
|
||||
/// <param name="table">The canonical table.</param>
|
||||
/// <returns>The composite key.</returns>
|
||||
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
|
||||
|
||||
/// <summary>
|
||||
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
|
||||
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
|
||||
/// ordinally would reject configurations that have always been valid — a gate that breaks working
|
||||
/// deployments is not defence in depth.</para>
|
||||
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
|
||||
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
|
||||
/// such a config resolving to what the operator wrote, and refusing to choose between two
|
||||
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
|
||||
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
|
||||
/// </remarks>
|
||||
/// <param name="candidates">The catalog spellings to match against.</param>
|
||||
/// <param name="authored">The authored name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when matched.</param>
|
||||
/// <returns><see langword="true"/> on an unambiguous match.</returns>
|
||||
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
|
||||
canonical = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
canonical = string.Empty;
|
||||
var matches = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (++matches > 1)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
canonical = candidate;
|
||||
}
|
||||
|
||||
return matches == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
|
||||
/// shape and the caller's tests can assert on the reason.
|
||||
/// </summary>
|
||||
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
|
||||
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
|
||||
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
|
||||
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
|
||||
|
||||
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
|
||||
/// <param name="Accepted">
|
||||
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
|
||||
/// <see cref="SqlGroupPlanner"/>.
|
||||
/// </param>
|
||||
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
|
||||
public sealed record SqlCatalogGateResult(
|
||||
IReadOnlyList<SqlTagDefinition> Accepted,
|
||||
IReadOnlyList<SqlCatalogRejection> Rejected);
|
||||
|
||||
/// <summary>
|
||||
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
|
||||
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
|
||||
/// sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
|
||||
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
|
||||
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
|
||||
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
|
||||
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
|
||||
/// than the whole story.</para>
|
||||
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
|
||||
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
|
||||
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
|
||||
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
|
||||
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
|
||||
/// must, or an operator has no way to find the typo.</para>
|
||||
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
|
||||
/// driver's table, so its RawPath resolves to nothing and it publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
|
||||
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
|
||||
/// down" rule the tag-table build already follows.</para>
|
||||
/// </remarks>
|
||||
public static class SqlCatalogGate
|
||||
{
|
||||
/// <summary>
|
||||
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
|
||||
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
|
||||
/// cannot see, so it cannot be allow-listed.
|
||||
/// </summary>
|
||||
private const int MaxNameParts = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
|
||||
/// with catalog-spelled identifiers and the rejected ones with reasons.
|
||||
/// </summary>
|
||||
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
|
||||
/// <param name="catalog">The catalog snapshot to validate against.</param>
|
||||
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
|
||||
/// <returns>The accepted and rejected sets.</returns>
|
||||
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
||||
public static SqlCatalogGateResult Apply(
|
||||
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definitions);
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
|
||||
var accepted = new List<SqlTagDefinition>();
|
||||
var rejected = new List<SqlCatalogRejection>();
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
|
||||
accepted.Add(canonical!);
|
||||
else
|
||||
rejected.Add(rejection!);
|
||||
}
|
||||
|
||||
return new SqlCatalogGateResult(accepted, rejected);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
|
||||
private static bool TryCanonicalize(
|
||||
SqlTagDefinition definition,
|
||||
SqlCatalog catalog,
|
||||
ISqlDialect dialect,
|
||||
out SqlTagDefinition? canonical,
|
||||
out SqlCatalogRejection? rejection)
|
||||
{
|
||||
canonical = null;
|
||||
rejection = null;
|
||||
|
||||
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
|
||||
return false;
|
||||
|
||||
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
|
||||
// model does not use is null and simply skipped — the planner enforces which are required.
|
||||
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var (field, authored) in new (string Field, string? Authored)[]
|
||||
{
|
||||
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
|
||||
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
|
||||
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
|
||||
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
|
||||
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
|
||||
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
|
||||
})
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored))
|
||||
{
|
||||
resolved[field] = authored;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsRenderableIdentifier(authored, dialect))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, field,
|
||||
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
|
||||
"or Unicode format characters); the value is withheld from this message because it " +
|
||||
"cannot be safely rendered");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, field,
|
||||
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
|
||||
"column under a case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved[field] = column;
|
||||
}
|
||||
|
||||
canonical = definition with
|
||||
{
|
||||
Table = SqlCatalog.QualifiedKey(schema, table),
|
||||
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
|
||||
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
|
||||
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
|
||||
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
|
||||
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
|
||||
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
|
||||
private static bool TryResolveRelation(
|
||||
SqlTagDefinition definition,
|
||||
SqlCatalog catalog,
|
||||
ISqlDialect dialect,
|
||||
out string schema,
|
||||
out string table,
|
||||
out SqlCatalogRejection? rejection)
|
||||
{
|
||||
schema = string.Empty;
|
||||
table = string.Empty;
|
||||
rejection = null;
|
||||
const string Field = nameof(SqlTagDefinition.Table);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(definition.Table))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
"the tag has no 'table'; author the table or view to read");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = definition.Table.Split('.');
|
||||
if (parts.Length > MaxNameParts)
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
|
||||
"validated against this connection's catalog, so a cross-database or linked-server name " +
|
||||
"cannot be allow-listed; expose the data through a view in this database instead");
|
||||
return false;
|
||||
}
|
||||
|
||||
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
|
||||
var authoredTable = parts[^1];
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (IsRenderableIdentifier(part, dialect)) continue;
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
|
||||
"containing control or Unicode format characters); the value is withheld from this message " +
|
||||
"because it cannot be safely rendered");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveSchema(authoredSchema, out schema))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
|
||||
"case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveTable(schema, authoredTable, out table))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
|
||||
(authoredSchema is null
|
||||
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
|
||||
: string.Empty));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
|
||||
/// embed in SQL and to render into a log line or an AdminUI label.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
|
||||
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
|
||||
/// discarded: only whether it threw is interesting here.
|
||||
/// </remarks>
|
||||
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialect.QuoteIdentifier(identifier);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
|
||||
/// dialect's catalog SQL (design §8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
|
||||
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
|
||||
/// one <see cref="ISqlDialect.ListColumnsSql"/> per distinct authored relation. A driver polling three
|
||||
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
|
||||
/// warehouse.</para>
|
||||
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
||||
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
|
||||
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
|
||||
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
|
||||
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
|
||||
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
|
||||
/// confidently-empty address space.</para>
|
||||
/// </remarks>
|
||||
internal static class SqlCatalogLoader
|
||||
{
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
|
||||
private const string SchemaColumn = "TABLE_SCHEMA";
|
||||
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
|
||||
private const string TableNameColumn = "TABLE_NAME";
|
||||
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||
/// </summary>
|
||||
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
|
||||
/// <param name="dialect">Supplies the catalog SQL.</param>
|
||||
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
|
||||
/// <param name="commandTimeout">Per-command server-side backstop.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The loaded catalog.</returns>
|
||||
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
|
||||
internal static async Task<SqlCatalog> LoadAsync(
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
IReadOnlyCollection<string> authoredTables,
|
||||
TimeSpan commandTimeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
var schemas = await QueryColumnAsync(
|
||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
||||
// that basis would report an authoring fault for what is actually a grant problem, and would send the
|
||||
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
|
||||
if (schemas.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
|
||||
"the catalog views, which is a grant problem rather than a tag-authoring one");
|
||||
|
||||
var defaultSchema = await ReadDefaultSchemaAsync(
|
||||
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
|
||||
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
|
||||
// one table and validating against another would be a silent wrong-column defect.
|
||||
foreach (var authored in authoredTables)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored)) continue;
|
||||
|
||||
var parts = authored.Split('.');
|
||||
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
|
||||
|
||||
var authoredTable = parts[^1];
|
||||
string schema;
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
schema = defaultSchema;
|
||||
}
|
||||
|
||||
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||
{
|
||||
tables = await QueryColumnAsync(
|
||||
connection, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, "@schema", schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
tablesBySchema[schema] = tables;
|
||||
}
|
||||
|
||||
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
|
||||
|
||||
var key = SqlCatalog.QualifiedKey(schema, table);
|
||||
if (columnsByTable.ContainsKey(key)) continue;
|
||||
|
||||
columnsByTable[key] = await QueryColumnAsync(
|
||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
},
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
|
||||
/// query answers null/blank.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
|
||||
/// visible schema keeps the common one-schema database working; with several visible schemas there is
|
||||
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
|
||||
/// </remarks>
|
||||
private static async Task<string> ReadDefaultSchemaAsync(
|
||||
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = dialect.DefaultSchemaSql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var schema = value is null or DBNull ? null : value.ToString();
|
||||
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
string columnName,
|
||||
Action<DbCommand> bind,
|
||||
int timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
bind(command);
|
||||
|
||||
var reader = await command
|
||||
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
|
||||
await using (reader.ConfigureAwait(false))
|
||||
{
|
||||
var ordinal = -1;
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
|
||||
if (reader.IsDBNull(ordinal)) continue;
|
||||
var value = reader.GetValue(ordinal)?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
|
||||
private static void Bind(DbCommand command, string name, string value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <c>Sql</c> driver config's <c>connectionStringRef</c> — a NAME — into the connection string
|
||||
/// it stands for (design §8.2). The deployed artifact carries only the name, so database credentials never
|
||||
/// ride in a config blob, never land in the central config DB, and never appear in a deployment diff.
|
||||
/// <para><b>The environment is read directly, on purpose.</b> A driver factory is invoked through
|
||||
/// <c>DriverFactoryRegistry</c>'s <c>(driverInstanceId, driverConfigJson)</c> closure — there is no
|
||||
/// <c>IConfiguration</c>, no <c>IServiceProvider</c> and no ambient scope at that seam, and widening the
|
||||
/// registry's signature for one driver would change every driver in the tree. The
|
||||
/// <c>Sql__ConnectionStrings__<ref></c> spelling is exactly the key .NET's environment-variable
|
||||
/// configuration provider would bind to <c>Sql:ConnectionStrings:<ref></c>, so an operator can set it
|
||||
/// the same way they set every other secret and a later move onto <c>IConfiguration</c> needs no
|
||||
/// re-provisioning.</para>
|
||||
/// <para><b>Nothing here ever emits the resolved value.</b> Messages name the environment
|
||||
/// <em>variable</em>, which is not a secret and is the operator's next action; the value itself is returned
|
||||
/// to exactly one caller and is otherwise unmentioned.</para>
|
||||
/// </summary>
|
||||
public static class SqlConnectionStringResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// The environment-variable prefix a <c>connectionStringRef</c> is appended to. The double underscore
|
||||
/// is .NET's configuration hierarchy separator, so this is the environment spelling of
|
||||
/// <c>Sql:ConnectionStrings:<ref></c>.
|
||||
/// </summary>
|
||||
public const string EnvironmentVariablePrefix = "Sql__ConnectionStrings__";
|
||||
|
||||
/// <summary>The environment variable a given <c>connectionStringRef</c> is read from.</summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The full environment-variable name.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
|
||||
public static string EnvironmentVariableFor(string connectionStringRef)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionStringRef);
|
||||
return string.Concat(EnvironmentVariablePrefix, connectionStringRef);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="connectionStringRef"/> to its connection string.
|
||||
/// <para>An absent <b>or blank</b> variable is a half-provisioned deployment and throws: handing an
|
||||
/// empty string to the provider would surface as an unintelligible ADO.NET error at the first poll,
|
||||
/// far from the missing secret that actually caused it.</para>
|
||||
/// </summary>
|
||||
/// <param name="connectionStringRef">The authored reference name.</param>
|
||||
/// <returns>The resolved connection string. <b>Treat as a secret</b> — never log it.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="connectionStringRef"/> is null, empty or whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The environment variable is absent or blank.</exception>
|
||||
public static string Resolve(string connectionStringRef)
|
||||
{
|
||||
var variable = EnvironmentVariableFor(connectionStringRef);
|
||||
var value = Environment.GetEnvironmentVariable(variable);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql connection string '{connectionStringRef}' is not provisioned: set the environment " +
|
||||
$"variable '{variable}' on every node that runs this driver. The connection string is " +
|
||||
$"deliberately not carried in the deployed configuration.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
|
||||
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
|
||||
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
|
||||
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
|
||||
/// <see cref="SqlPollReader"/> owns the read path.
|
||||
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
|
||||
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
|
||||
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
|
||||
/// new capability, not a flag flip.</para>
|
||||
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
|
||||
/// <see cref="IReadable"/> literally: it throws only when the database cannot be reached, and turns
|
||||
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
|
||||
/// <em>successfully</em>, so <see cref="PollGroupEngine"/> sees a clean tick, does not back off, and does
|
||||
/// not call <see cref="HandlePollError"/>. Without <see cref="ObservePollOutcome"/> below, a driver whose
|
||||
/// every value is <see cref="SqlStatusCodes.BadTimeout"/> would keep reporting
|
||||
/// <see cref="DriverState.Healthy"/>. See that method for the exact rule and for why it does not
|
||||
/// manufacture an exception to force backoff.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver-type string this driver registers under — <see cref="DriverTypeNames.Sql"/>, the single
|
||||
/// source of truth the deploy pipeline stores in <c>DriverInstance.DriverType</c> and every dispatch
|
||||
/// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so
|
||||
/// <c>DriverTypeNamesGuardTests</c>' bidirectional-parity check is satisfied).
|
||||
/// </summary>
|
||||
public const string DriverTypeName = DriverTypeNames.Sql;
|
||||
|
||||
/// <summary>Shown for a connection string that names no recognisable server.</summary>
|
||||
private const string UnknownEndpoint = "(unknown sql endpoint)";
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Connection-string keys that name the server, in the order they are consulted.</summary>
|
||||
private static readonly string[] ServerKeys =
|
||||
["server", "data source", "datasource", "host", "address", "addr", "network address"];
|
||||
|
||||
/// <summary>Connection-string keys that name the database.</summary>
|
||||
private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
|
||||
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly SqlDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ISqlDialect _dialect;
|
||||
private readonly DbProviderFactory _factory;
|
||||
|
||||
/// <summary>
|
||||
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
|
||||
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
|
||||
/// </summary>
|
||||
private readonly string _connectionString;
|
||||
|
||||
private readonly ILogger<SqlDriver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// The authored RawPath → definition table, held as an <b>immutable snapshot swapped atomically</b>
|
||||
/// rather than as a mutated dictionary (the shape <c>ModbusDriver</c> uses).
|
||||
/// <see cref="ReinitializeAsync"/> rebuilds this table while poll loops are reading it; clearing and
|
||||
/// refilling a shared <see cref="Dictionary{TKey,TValue}"/> under that concurrency is a torn read at
|
||||
/// best and an <see cref="InvalidOperationException"/> inside a poll at worst.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
|
||||
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
|
||||
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
|
||||
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
|
||||
/// reach a query.
|
||||
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
|
||||
/// node: §8.1's specified outcome is that it publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, and a status code can only be published by a node
|
||||
/// that is there. Dropping rejected tags from the authored table too would delete the node instead,
|
||||
/// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse
|
||||
/// failure for the operator trying to find their typo.</para>
|
||||
/// <para>Swapped atomically, for the same reason the authored table is.</para>
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
|
||||
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
|
||||
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
||||
|
||||
/// <summary>
|
||||
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
|
||||
/// delegate must read this driver's live authored table, which does not exist before the driver does.
|
||||
/// </summary>
|
||||
private readonly SqlPollReader _reader;
|
||||
|
||||
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
||||
private readonly PollGroupEngine _poll;
|
||||
|
||||
// Single logical host: one driver instance dials one connection string. HostName is the endpoint
|
||||
// description (server[/database]) so the Admin UI shows operators where to look.
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// ---- ctor + identity ----
|
||||
|
||||
/// <summary>Initializes a new <c>Sql</c> driver instance.</summary>
|
||||
/// <param name="options">The driver's typed configuration (the factory applies the documented defaults).</param>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="dialect">
|
||||
/// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
|
||||
/// </param>
|
||||
/// <param name="connectionString">
|
||||
/// The <b>already-resolved</b> connection string. The driver never resolves a
|
||||
/// <c>connectionStringRef</c> itself and never logs this value.
|
||||
/// </param>
|
||||
/// <param name="factory">
|
||||
/// Creates the provider's connections. Defaults to <paramref name="dialect"/>'s factory; passed
|
||||
/// explicitly by tests that need to observe or delay connection creation.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
||||
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
|
||||
public SqlDriver(
|
||||
SqlDriverOptions options,
|
||||
string driverInstanceId,
|
||||
ISqlDialect dialect,
|
||||
string connectionString,
|
||||
DbProviderFactory? factory = null,
|
||||
ILogger<SqlDriver>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
|
||||
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_dialect = dialect;
|
||||
_factory = factory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
_logger = logger ?? NullLogger<SqlDriver>.Instance;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
|
||||
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
|
||||
_reader = new SqlPollReader(
|
||||
_factory,
|
||||
connectionString,
|
||||
dialect,
|
||||
commandTimeout: options.CommandTimeout,
|
||||
operationTimeout: options.OperationTimeout,
|
||||
maxConcurrentGroups: options.MaxConcurrentGroups,
|
||||
nullIsBad: options.NullIsBad,
|
||||
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
||||
logger: _logger);
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeName;
|
||||
|
||||
/// <summary>
|
||||
/// The credential-free description of the database this instance polls — <c>server/database</c> where
|
||||
/// the connection string names both. This is the only rendering of the connection string that may
|
||||
/// appear in a log, a health message, or the Admin UI.
|
||||
/// </summary>
|
||||
public string Endpoint { get; }
|
||||
|
||||
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
||||
public string HostName => Endpoint;
|
||||
|
||||
/// <summary>Active polled-subscription count. Diagnostics + disposal assertions.</summary>
|
||||
internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
|
||||
|
||||
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
|
||||
|
||||
/// <summary>
|
||||
/// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
|
||||
|
||||
/// <summary>
|
||||
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
|
||||
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
|
||||
/// rejected must miss here so the reader publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
|
||||
/// </summary>
|
||||
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
|
||||
|
||||
// ---- IDriver lifecycle ----
|
||||
|
||||
/// <summary>
|
||||
/// Builds the authored RawPath table and proves the database is reachable.
|
||||
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
|
||||
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
|
||||
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
|
||||
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
|
||||
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
|
||||
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
|
||||
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
|
||||
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
|
||||
/// (<see cref="ApplyCatalogGateAsync"/>), which is the second and last piece of I/O. It runs after
|
||||
/// liveness because it needs a working connection, and before the driver reports Healthy because the
|
||||
/// tag table it publishes must be the validated one — a poll must never see an unvalidated
|
||||
/// identifier.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
BuildTagTable();
|
||||
|
||||
try
|
||||
{
|
||||
await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The caller tore the initialization down; not a database verdict.
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is
|
||||
// dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's
|
||||
// message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an
|
||||
// unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only
|
||||
// the exception TYPE reaches LastError/host status; the full exception object goes to the log
|
||||
// SINK via the structured logger's exception parameter (below), never into a rendered string.
|
||||
var message =
|
||||
$"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " +
|
||||
$"Check the database is reachable and the connection string is valid.";
|
||||
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
// Separate try from liveness so the operator surface names the stage that actually failed: "could
|
||||
// not reach the database" and "reached it but could not read its catalog" send an operator to
|
||||
// different places, and the second is usually a GRANT rather than an outage.
|
||||
try
|
||||
{
|
||||
await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same discipline as above: exception TYPE only on the operator surface, full exception to the
|
||||
// log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of
|
||||
// evidence — so this faults the driver (and retries) rather than rejecting every tag and serving
|
||||
// a confidently-empty address space.
|
||||
var message =
|
||||
$"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " +
|
||||
$"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " +
|
||||
"can read the catalog views.";
|
||||
_logger.LogError(ex,
|
||||
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
TransitionHostTo(HostState.Running);
|
||||
_logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
|
||||
_driverInstanceId, Endpoint, Tags.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => ReadHealth();
|
||||
|
||||
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
|
||||
/// <returns>Always zero.</returns>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
|
||||
/// could only produce the same nodes.
|
||||
/// </summary>
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <summary>
|
||||
/// False — <see cref="DiscoverAsync"/> replays authored tags rather than enumerating the backend, so
|
||||
/// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
|
||||
/// a separate surface: <c>Driver.Sql.Browser</c>.)
|
||||
/// </summary>
|
||||
public bool SupportsOnlineDiscovery => false;
|
||||
|
||||
/// <summary>
|
||||
/// Streams the authored tags as read-only variables.
|
||||
/// <para>An undeclared tag materialises as <see cref="DriverDataType.String"/> — the same fallback
|
||||
/// <see cref="ISqlDialect.MapColumnType"/> uses for an unrecognised column type — because a column's
|
||||
/// real type is only known once a poll has returned result-set metadata, which has not happened at
|
||||
/// discovery time. Author <c>"type"</c> on a numeric tag.</para>
|
||||
/// </summary>
|
||||
/// <param name="builder">The address-space builder to stream discovered nodes into.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task — discovery is synchronous over the authored table.</returns>
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder(DriverTypeName, DriverTypeName);
|
||||
foreach (var tag in Tags.Values)
|
||||
{
|
||||
if (tag.DeclaredType is null)
|
||||
{
|
||||
// Discovery cannot infer a column's real type — that needs live result-set metadata, which
|
||||
// only a poll returns — so an omitted-type tag materialises as String (the same fallback
|
||||
// ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual,
|
||||
// possibly numeric, CLR value against that String node. Nothing else signals the operator to
|
||||
// this declared-vs-published mismatch, so warn and point them at the fix.
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " +
|
||||
"will then publish numeric values against a String node. Author an explicit \"type\" on " +
|
||||
"numeric tags. Driver={DriverInstanceId}",
|
||||
tag.Name, _driverInstanceId);
|
||||
}
|
||||
|
||||
folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
// v1 is read-only: no tag, and no configuration, can widen this.
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Reads a batch, delegating to <see cref="SqlPollReader"/> and classifying what came back
|
||||
/// (<see cref="ObservePollOutcome"/>).
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>One snapshot per reference, at the same index.</returns>
|
||||
/// <exception cref="DbException">The database could not be reached — the engine backs off on this.</exception>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
// Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
|
||||
// unreachable" and degrade a perfectly healthy driver.
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
try
|
||||
{
|
||||
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||||
ObservePollOutcome(snapshots);
|
||||
return snapshots;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Teardown, not a database verdict — leave health and host state exactly as they were.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The reader throws for one reason only: opening a connection failed (IReadable's contract).
|
||||
_logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
|
||||
_driverInstanceId, Endpoint);
|
||||
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
|
||||
// on the log sink via the LogWarning exception parameter above.
|
||||
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via the shared engine) ----
|
||||
|
||||
/// <summary>
|
||||
/// Registers a polled subscription.
|
||||
/// <para>A non-positive <paramref name="publishingInterval"/> falls back to the configured
|
||||
/// <see cref="SqlDriverOptions.DefaultPollInterval"/>; anything faster than
|
||||
/// <see cref="PollGroupEngine.DefaultMinInterval"/> is floored by the engine.</para>
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to poll.</param>
|
||||
/// <param name="publishingInterval">The requested publishing interval.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The subscription handle.</returns>
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
|
||||
return Task.FromResult(_poll.Subscribe(fullReferences, interval));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
/// <summary>
|
||||
/// The single logical host this instance dials. There is no background probe loop: the state is a
|
||||
/// by-product of the Initialize-time liveness check and of every poll, so the report is always a
|
||||
/// statement about traffic that actually happened rather than about a synthetic ping.
|
||||
/// </summary>
|
||||
/// <returns>A one-element list describing the configured database endpoint.</returns>
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
// ---- health + host-state classification ----
|
||||
|
||||
/// <summary>
|
||||
/// Classifies one poll's snapshots into a health verdict and a host state.
|
||||
/// <list type="bullet">
|
||||
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
|
||||
/// host is Running.</item>
|
||||
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
|
||||
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
|
||||
/// the host is Stopped.</item>
|
||||
/// <item>Bad codes that are <b>authoring</b> facts — an unresolvable RawPath, an absent row, a
|
||||
/// type mismatch, a rejected definition — change nothing. A tag typo must never report the
|
||||
/// database as down and send an operator to the wrong system.</item>
|
||||
/// </list>
|
||||
/// <para><b>Why this does not throw to force poll-engine backoff.</b> Backoff would require turning
|
||||
/// an all-<c>BadTimeout</c> batch into an exception, and the engine's exception path publishes
|
||||
/// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
|
||||
/// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
|
||||
/// already bounded by <c>operationTimeout</c> and the reader never holds more than
|
||||
/// <c>maxConcurrentGroups</c> connections however long the database stays frozen. So a frozen
|
||||
/// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
|
||||
/// cadence rather than backed off from.</para>
|
||||
/// </summary>
|
||||
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
|
||||
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
{
|
||||
if (snapshots.Count == 0) return;
|
||||
|
||||
var good = 0;
|
||||
var unreachable = 0;
|
||||
foreach (var snapshot in snapshots)
|
||||
{
|
||||
if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
|
||||
else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
|
||||
}
|
||||
|
||||
if (unreachable > 0)
|
||||
{
|
||||
Degrade(
|
||||
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
|
||||
$"{Endpoint} on the last poll.");
|
||||
if (good > 0)
|
||||
{
|
||||
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
|
||||
TouchLastSuccessfulRead();
|
||||
TransitionHostTo(HostState.Running);
|
||||
}
|
||||
else
|
||||
{
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
|
||||
|
||||
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
|
||||
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
|
||||
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
||||
// to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
|
||||
SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
TransitionHostTo(HostState.Running);
|
||||
}
|
||||
|
||||
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
|
||||
private static bool IsConnectionClass(uint statusCode)
|
||||
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
|
||||
/// touch host state: the engine also reports its own contract violations here, which say nothing
|
||||
/// about connectivity — the unreachable-database transition is raised by <see cref="ReadAsync"/>,
|
||||
/// which knows the exception came from the reader.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception the poll engine caught.</param>
|
||||
internal void HandlePollError(Exception ex)
|
||||
{
|
||||
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
|
||||
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
|
||||
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
|
||||
// guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
|
||||
if (ex is DbException) return;
|
||||
|
||||
// Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
|
||||
// (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
|
||||
// credential fragments) — the operator surface carries only the exception TYPE, never ex.Message.
|
||||
// The full exception, with its text, still reaches the log SINK via the exception parameter. This is
|
||||
// the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check.
|
||||
_logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
|
||||
_driverInstanceId, Endpoint);
|
||||
Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Degrades health, preserving <c>LastSuccessfulRead</c> and never downgrading
|
||||
/// <see cref="DriverState.Faulted"/> — a config-level verdict that only a successful read or an
|
||||
/// operator reinitialize may clear.
|
||||
/// </summary>
|
||||
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
|
||||
private void Degrade(string reason)
|
||||
{
|
||||
var current = ReadHealth();
|
||||
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
|
||||
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
|
||||
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
|
||||
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
|
||||
/// <see cref="ReinitializeAsync"/> just faulted.
|
||||
/// </summary>
|
||||
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
|
||||
private void SetHealthUnlessFaulted(DriverHealth value)
|
||||
{
|
||||
if (ReadHealth().State == DriverState.Faulted) return;
|
||||
WriteHealth(value);
|
||||
}
|
||||
|
||||
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
|
||||
private void TouchLastSuccessfulRead()
|
||||
{
|
||||
var current = ReadHealth();
|
||||
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
||||
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||
|
||||
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
||||
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||
|
||||
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
|
||||
private void TransitionHostTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
// ---- authored tag table ----
|
||||
|
||||
/// <summary>
|
||||
/// Maps every authored <see cref="RawTagEntry"/> through <see cref="SqlEquipmentTagParser.TryParse"/>
|
||||
/// and publishes the result as one atomic snapshot. A blob that does not map is <b>logged and
|
||||
/// skipped</b>, never thrown: one malformed tag must not take the whole driver — and therefore every
|
||||
/// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
|
||||
/// publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> rather than someone else's row.
|
||||
/// </summary>
|
||||
private void BuildTagTable()
|
||||
{
|
||||
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
|
||||
table[entry.RawPath] = definition;
|
||||
else
|
||||
_logger.LogWarning(
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's
|
||||
// try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and
|
||||
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
|
||||
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
|
||||
// branch above follows — rather than fault the driver over a single entry.
|
||||
_logger.LogError(
|
||||
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
|
||||
Volatile.Write(ref _tagsByRawPath, table);
|
||||
|
||||
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
|
||||
// previous generation's validated table in place) means a Reinitialize whose gate then fails cannot
|
||||
// keep polling the OLD identifiers against a database whose schema may be exactly what changed.
|
||||
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
// ---- catalog gate (design §8.1) ----
|
||||
|
||||
/// <summary>
|
||||
/// Validates every authored identifier against the live catalog and republishes the tag table with
|
||||
/// catalog spellings, dropping the tags that do not resolve (design §8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why this must run before the driver reports Healthy.</b> The table this swaps in is what
|
||||
/// every poll resolves against. Running it later — or in the background — would leave a window in
|
||||
/// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate
|
||||
/// exists to close.</para>
|
||||
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
|
||||
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
|
||||
/// typo must not stop the other tags on that database, the same rule
|
||||
/// <see cref="BuildTagTable"/> follows for a malformed blob. Each drop is logged at Warning with the
|
||||
/// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is
|
||||
/// unsupportable.</para>
|
||||
/// <para><b>No tags ⇒ no I/O.</b> A driver with nothing authored has nothing to validate, and issuing
|
||||
/// catalog queries to prove that would be a round-trip that can only fail.</para>
|
||||
/// <para>Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work
|
||||
/// runs on the thread pool so a provider implementing the async path synchronously blocks a pool
|
||||
/// thread rather than wedging Initialize forever with no retry.</para>
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
|
||||
private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var authored = Tags;
|
||||
if (authored.Count == 0) return;
|
||||
|
||||
var tables = authored.Values
|
||||
.Select(definition => definition.Table)
|
||||
.Where(table => !string.IsNullOrWhiteSpace(table))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var catalog = await RunBoundedAsync(
|
||||
token => LoadCatalogAsync(tables, token),
|
||||
_options.OperationTimeout,
|
||||
"the catalog queries did not return",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
|
||||
|
||||
foreach (var rejection in result.Rejected)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish "
|
||||
+ "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}",
|
||||
rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId);
|
||||
}
|
||||
|
||||
var validated = new Dictionary<string, SqlTagDefinition>(result.Accepted.Count, StringComparer.Ordinal);
|
||||
foreach (var definition in result.Accepted) validated[definition.Name] = definition;
|
||||
|
||||
// Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps
|
||||
// its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath).
|
||||
Volatile.Write(ref _polledByRawPath, validated);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) "
|
||||
+ "across {Tables} table(s) on {Endpoint}.",
|
||||
_driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint);
|
||||
}
|
||||
|
||||
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
|
||||
private async Task<SqlCatalog> LoadCatalogAsync(
|
||||
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"the {_dialect.Provider} provider factory returned no connection.");
|
||||
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = _connectionString;
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await SqlCatalogLoader
|
||||
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- liveness ----
|
||||
|
||||
/// <summary>
|
||||
/// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
|
||||
/// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
|
||||
/// long-lived connection here would only be a second thing to keep alive.
|
||||
/// <para><b>Bounded by wall-clock, not only by the token</b> (the R2-01 / STAB-14 lesson): some ADO.NET
|
||||
/// providers implement the async path synchronously, and a wedged socket can hang inside the
|
||||
/// provider's own cancellation handshake. If Initialize hung there, <c>DriverInstanceActor</c>'s init
|
||||
/// task would never complete and the driver would sit in Connecting forever with no retry. The work
|
||||
/// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
|
||||
/// an abandoned attempt still owns and disposes its own connection.</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>A task that completes when the database has answered.</returns>
|
||||
private Task VerifyLivenessAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(
|
||||
async token => { await PingAsync(token).ConfigureAwait(false); return true; },
|
||||
_options.CommandTimeout,
|
||||
"the liveness statement did not return",
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
|
||||
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists
|
||||
/// because a token is not a deadline: some ADO.NET providers implement the async path synchronously,
|
||||
/// and a wedged socket can hang <em>inside</em> the provider's own cancellation handshake. Without an
|
||||
/// independent wall clock, <c>DriverInstanceActor</c>'s init task would never complete and the driver
|
||||
/// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped.</para>
|
||||
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
|
||||
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
|
||||
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
|
||||
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The work's result type.</typeparam>
|
||||
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
|
||||
/// <param name="budget">The wall-clock allowance.</param>
|
||||
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>The work's result.</returns>
|
||||
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
|
||||
private static async Task<T> RunBoundedAsync<T>(
|
||||
Func<CancellationToken, Task<T>> work,
|
||||
TimeSpan budget,
|
||||
string what,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
Task<T> running;
|
||||
try
|
||||
{
|
||||
deadline.CancelAfter(budget);
|
||||
running = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try { return await work(deadline.Token).ConfigureAwait(false); }
|
||||
finally { deadline.Dispose(); }
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ownership of the CTS transfers to the work task; release it only if that task never started.
|
||||
deadline.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Our deadline fired and the provider DID honour the linked token.
|
||||
Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a connection and executes the dialect's cheapest proof-of-life statement.</summary>
|
||||
private async Task PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"the {_dialect.Provider} provider factory returned no connection.");
|
||||
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = _connectionString;
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = _dialect.LivenessSql;
|
||||
// ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
|
||||
command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
|
||||
/// task exception. The task still owns — and disposes — its own connection.
|
||||
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
|
||||
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
|
||||
/// </summary>
|
||||
private static void Detach(Task work)
|
||||
=> _ = work.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
// ---- endpoint description ----
|
||||
|
||||
/// <summary>
|
||||
/// Renders a connection string as <c>server/database</c>, reading only those two keys.
|
||||
/// <para>This is the <b>only</b> function permitted to look at the connection string outside the
|
||||
/// provider call, and it exists so that no other code is ever tempted to log the string itself:
|
||||
/// credentials live in <c>User ID</c> / <c>Password</c> / <c>Authentication</c>, which are never
|
||||
/// read here. A string that cannot be parsed yields <see cref="UnknownEndpoint"/> — an unusable
|
||||
/// description must not become a crash at construction.</para>
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The resolved connection string.</param>
|
||||
/// <returns>A credential-free description safe to log and display.</returns>
|
||||
private static string DescribeEndpoint(string connectionString)
|
||||
{
|
||||
try
|
||||
{
|
||||
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
|
||||
var server = FirstValue(builder, ServerKeys);
|
||||
if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
|
||||
var database = FirstValue(builder, DatabaseKeys);
|
||||
return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return UnknownEndpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the first of <paramref name="keys"/> the builder carries; null when it carries none.</summary>
|
||||
private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
// DbConnectionStringBuilder's key comparison is case-insensitive.
|
||||
if (builder.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
var text = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(text)) return text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- teardown ----
|
||||
|
||||
/// <summary>
|
||||
/// Performs the same teardown as <see cref="ShutdownAsync"/>, so a caller that only uses
|
||||
/// <c>await using</c> does not leak the poll loops.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
|
||||
/// to call any number of times, in any order, which is what makes
|
||||
/// <c>ShutdownAsync</c>-then-<c>DisposeAsync</c> (and <see cref="ReinitializeAsync"/>) safe.
|
||||
/// <para>There is no connection to close: the reader opens and disposes one per poll.</para>
|
||||
/// </summary>
|
||||
private async Task TeardownAsync()
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
|
||||
/// <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
|
||||
/// <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
|
||||
/// rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
|
||||
/// <para><b>This type owns the config validation the layers below deliberately skip.</b>
|
||||
/// <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
|
||||
/// <c>operationTimeout</c>/<c>commandTimeout</c> pair on purpose (the frozen-database tests need that
|
||||
/// shape), so the factory is the only place a deployment can be stopped before it silently loses one of
|
||||
/// its two independent deadlines. Every knob is checked here, once, at construction.</para>
|
||||
/// <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
|
||||
/// <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
|
||||
/// round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
|
||||
/// numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
|
||||
/// <para><b>The resolved connection string is a secret and never leaves this method except into the
|
||||
/// driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
|
||||
/// environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
|
||||
/// rendering — never the string itself.</para>
|
||||
/// </summary>
|
||||
public static class SqlDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver-type string this factory registers under — chained off
|
||||
/// <see cref="SqlDriver.DriverTypeName"/>, which is
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
|
||||
/// </summary>
|
||||
public const string DriverTypeName = SqlDriver.DriverTypeName;
|
||||
|
||||
/// <summary>
|
||||
/// How a <c>Sql</c> config blob is read.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
|
||||
/// ordinal, and always writes the name (the enum-serialization guard).</item>
|
||||
/// <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
|
||||
/// schema version must not brick the driver.</item>
|
||||
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
|
||||
/// (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
|
||||
/// what makes a <em>write</em> round-trip to the authored shape.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
|
||||
internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <c>Sql</c> factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
|
||||
/// driver instance; without it the driver runs with the null logger.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
||||
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>
|
||||
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
|
||||
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
|
||||
/// the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
|
||||
/// exist until the driver does.</para>
|
||||
/// <para>No I/O happens here. The database is first contacted by
|
||||
/// <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
|
||||
/// Reconnecting rather than a deployment that cannot be constructed.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="SqlDriver"/>.</returns>
|
||||
/// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
|
||||
/// construct, carries an invalid knob, or the referenced connection string is not provisioned.
|
||||
/// </exception>
|
||||
public static SqlDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = Deserialize(driverInstanceId, driverConfigJson);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
|
||||
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
|
||||
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
|
||||
}
|
||||
|
||||
var dialect = CreateDialect(dto.Provider, driverInstanceId);
|
||||
|
||||
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
|
||||
// secret in hand — which is exactly where a careless interpolation would leak it.
|
||||
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
|
||||
var options = BuildOptions(dto, driverInstanceId);
|
||||
|
||||
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
||||
?? NullLogger.Instance;
|
||||
|
||||
if (dto.AllowWrites is true)
|
||||
{
|
||||
// Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
|
||||
// would brick a deployment for a flag that cannot do harm. The operator who believes writes are
|
||||
// on, however, can — so this is loud.
|
||||
logger.LogWarning(
|
||||
"Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
|
||||
"driver is read-only (it implements no write capability), so no configuration can enable a " +
|
||||
"write. Remove the flag, or expect reads only.",
|
||||
driverInstanceId);
|
||||
}
|
||||
|
||||
var driver = new SqlDriver(
|
||||
options,
|
||||
driverInstanceId,
|
||||
dialect,
|
||||
connectionString,
|
||||
factory: null,
|
||||
logger: loggerFactory?.CreateLogger<SqlDriver>());
|
||||
|
||||
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
|
||||
// permitted outside the provider call.
|
||||
logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
|
||||
"authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
|
||||
driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
|
||||
options.DefaultPollInterval, options.OperationTimeout);
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
|
||||
private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SqlDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' deserialised to null");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the provider seam.
|
||||
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
|
||||
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
|
||||
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
|
||||
/// </summary>
|
||||
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
|
||||
=> provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
|
||||
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies the documented defaults and validates every knob (design §5.1 / §8.3).
|
||||
/// <para>Internal so the defaults and the rejections can be asserted directly, without a database or a
|
||||
/// provisioned connection string standing between the test and the rule.</para>
|
||||
/// </summary>
|
||||
/// <param name="dto">The deserialised config blob.</param>
|
||||
/// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
|
||||
/// <returns>The typed options the driver is constructed with.</returns>
|
||||
/// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
|
||||
internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
|
||||
var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
|
||||
var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
|
||||
var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;
|
||||
|
||||
RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
|
||||
RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
|
||||
RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
|
||||
if (maxConcurrentGroups < 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
|
||||
$"it must be at least 1.");
|
||||
}
|
||||
|
||||
// Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
|
||||
// server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
|
||||
// wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
|
||||
// first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
|
||||
// two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
|
||||
if (operationTimeout <= commandTimeout)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
|
||||
$"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
|
||||
$"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
|
||||
$"never fire.");
|
||||
}
|
||||
|
||||
return new SqlDriverOptions
|
||||
{
|
||||
RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
|
||||
DefaultPollInterval = defaultPollInterval,
|
||||
OperationTimeout = operationTimeout,
|
||||
CommandTimeout = commandTimeout,
|
||||
MaxConcurrentGroups = maxConcurrentGroups,
|
||||
NullIsBad = dto.NullIsBad ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rejects a non-positive duration, naming the authored field.</summary>
|
||||
private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
|
||||
{
|
||||
if (value <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The typed form of a <c>Sql</c> driver instance's configuration — what
|
||||
/// <see cref="SqlDriverConfigDto"/> becomes once the factory has applied its defaults (design §5.1).
|
||||
/// Mirrors <c>ModbusDriverOptions</c>: the driver instance is constructed with these, and its
|
||||
/// <see cref="SqlDriver.InitializeAsync"/> serves them rather than re-parsing the config JSON.
|
||||
/// <para><b>No connection string here.</b> The resolved connection string is a separate constructor
|
||||
/// argument, because it is a secret and these options are safe to log, diff and hold in a snapshot.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The authored raw tags this driver serves. The deploy artifact hands each authored raw <c>Tag</c>
|
||||
/// as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob); the driver maps
|
||||
/// each through <see cref="SqlEquipmentTagParser.TryParse"/> into its RawPath → definition table.
|
||||
/// A SQL source has no tag-discovery protocol — the driver serves exactly these.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Poll interval used when a subscriber does not ask for one (design §4: <c>defaultPollInterval</c>,
|
||||
/// default 5 s). A subscriber that names an interval gets that interval, floored by
|
||||
/// <see cref="PollGroupEngine.DefaultMinInterval"/>.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultPollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// The client-side wall-clock ceiling on one group query (design §8.3, default 15 s). A breach
|
||||
/// publishes <see cref="SqlStatusCodes.BadTimeout"/> for that group's tags.
|
||||
/// </summary>
|
||||
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// The ADO.NET <c>CommandTimeout</c> server-side backstop (default 10 s), also the bound on the
|
||||
/// Initialize-time liveness check.
|
||||
/// <para>Authoring must keep <see cref="OperationTimeout"/> strictly greater than this (design §8.3);
|
||||
/// inverted, the client-side abort always fires first and masks the backstop. <b>That rule is
|
||||
/// enforced by config validation in the factory, not here</b> — the reader deliberately stays usable
|
||||
/// with the order inverted so the frozen-database tests can prove the client-side bound fires.</para>
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Cap on concurrently executing group queries — and therefore on concurrently open connections.</summary>
|
||||
public int MaxConcurrentGroups { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, a present row whose value cell is NULL publishes
|
||||
/// <see cref="SqlStatusCodes.Bad"/> instead of the default <see cref="SqlStatusCodes.Uncertain"/>.
|
||||
/// It never governs an absent row, which is always <see cref="SqlStatusCodes.BadNoData"/>.
|
||||
/// </summary>
|
||||
public bool NullIsBad { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
|
||||
/// dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
|
||||
/// green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
|
||||
/// <para><b>Parses the SAME factory DTO with the SAME converter as
|
||||
/// <see cref="SqlDriverFactoryExtensions"/></b> (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
|
||||
/// config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
|
||||
/// <c>provider</c> parses here exactly as it does at the factory, and the same
|
||||
/// <c>connectionStringRef</c> resolves the same way.</para>
|
||||
/// <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
|
||||
/// missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
|
||||
/// timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
|
||||
/// catch.</para>
|
||||
/// <para><b>The resolved connection string never reaches the result message.</b> A red result names the
|
||||
/// failure class, never the string — the same credential discipline the factory and the driver keep.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
|
||||
/// <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
|
||||
/// probe can run against the SQLite fixture with no SQL Server and no network.
|
||||
/// </summary>
|
||||
private readonly Func<SqlProvider, ISqlDialect> _dialectFor;
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
|
||||
/// the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
|
||||
/// catalog SQL. Null in production: the dialect's own factory is used.
|
||||
/// </summary>
|
||||
private readonly DbProviderFactory? _factoryOverride;
|
||||
|
||||
/// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
|
||||
public SqlDriverProbe()
|
||||
: this(DefaultDialectFor, factoryOverride: null)
|
||||
{
|
||||
}
|
||||
|
||||
private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
|
||||
{
|
||||
_dialectFor = dialectFor;
|
||||
_factoryOverride = factoryOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
|
||||
/// <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
|
||||
/// runs unmodified.
|
||||
/// </summary>
|
||||
/// <param name="factory">The provider factory the probe opens connections from.</param>
|
||||
/// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
|
||||
/// <returns>A probe wired to the supplied factory + dialect.</returns>
|
||||
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
||||
internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
return new SqlDriverProbe(_ => dialect, factory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => SqlDriver.DriverTypeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
|
||||
SqlDriverConfigDto? dto;
|
||||
try
|
||||
{
|
||||
dto = System.Text.Json.JsonSerializer.Deserialize<SqlDriverConfigDto>(
|
||||
configJson, SqlDriverFactoryExtensions.JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
|
||||
{
|
||||
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
|
||||
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
|
||||
return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);
|
||||
|
||||
ISqlDialect dialect;
|
||||
string connectionString;
|
||||
try
|
||||
{
|
||||
dialect = _dialectFor(dto.Provider);
|
||||
connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
|
||||
// safe and actionable (it names the environment variable the operator must set).
|
||||
return new DriverProbeResult(
|
||||
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
|
||||
}
|
||||
|
||||
var factory = _factoryOverride ?? dialect.Factory;
|
||||
return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
|
||||
/// liveness statement, and returns green with latency. Any failure becomes a red result whose message
|
||||
/// names the failure class — <b>never the connection string</b> (a provider exception can embed the
|
||||
/// data source, so its <c>.Message</c> is deliberately not surfaced).
|
||||
/// </summary>
|
||||
private static async Task<DriverProbeResult> RunLivenessAsync(
|
||||
DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var connection = factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"the {dialect.Provider} provider factory returned no connection.");
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = connectionString;
|
||||
await connection.OpenAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = dialect.LivenessSql;
|
||||
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return new DriverProbeResult(false, "Probe cancelled.", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new DriverProbeResult(
|
||||
false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The provider's own message can carry the data source (host/database), which is not a secret but
|
||||
// is more than the operator needs — and keeping the message to a fixed class is what guarantees no
|
||||
// credential-bearing connection string can ever slip through. Name the exception TYPE only.
|
||||
return new DriverProbeResult(
|
||||
false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
|
||||
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
|
||||
{
|
||||
SqlProvider.SqlServer => new SqlServerDialect(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Folds a set of <see cref="SqlTagDefinition"/>s into the minimum number of parameterized queries
|
||||
/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
|
||||
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> when every
|
||||
/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
|
||||
/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
|
||||
/// <c>SqlGroupPlannerTests</c>.</para>
|
||||
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
|
||||
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
|
||||
/// interpolation of tag content into SQL anywhere in this type.</para>
|
||||
/// </summary>
|
||||
public static class SqlGroupPlanner
|
||||
{
|
||||
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
|
||||
private const string KeyMarkerPrefix = "@k";
|
||||
|
||||
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
|
||||
private const string RowSelectorMarker = "@w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
/// <para>Deterministic: groups appear in the input order of their first member, members keep their input
|
||||
/// order within a group, and parameters keep the first-appearance order of their distinct values — so a
|
||||
/// given tag list always yields byte-identical SQL.</para>
|
||||
/// </summary>
|
||||
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
|
||||
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
|
||||
/// <returns>One plan per distinct group key.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
|
||||
/// identifier cannot be quoted (an empty table-name part, a control character — see
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/>). <see cref="SqlEquipmentTagParser"/> rejects all of these
|
||||
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
|
||||
/// </exception>
|
||||
/// <exception cref="NotSupportedException">
|
||||
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
|
||||
/// <b>Deliberately loud rather than skipped:</b> silently dropping a tag would leave an authored node
|
||||
/// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
|
||||
/// </exception>
|
||||
public static IReadOnlyList<SqlQueryPlan> Plan(IEnumerable<SqlTagDefinition> tags, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
|
||||
// Ordered grouping: the dictionary decides membership, the list decides emission order.
|
||||
var order = new List<object>();
|
||||
var groups = new Dictionary<object, List<SqlTagDefinition>>();
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
var key = BuildGroupKey(tag);
|
||||
if (!groups.TryGetValue(key, out var members))
|
||||
{
|
||||
members = [];
|
||||
groups.Add(key, members);
|
||||
order.Add(key);
|
||||
}
|
||||
|
||||
members.Add(tag);
|
||||
}
|
||||
|
||||
var plans = new List<SqlQueryPlan>(order.Count);
|
||||
foreach (var key in order)
|
||||
{
|
||||
var members = groups[key];
|
||||
plans.Add(members[0].Model switch
|
||||
{
|
||||
SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
|
||||
SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
|
||||
_ => throw UnsupportedModel(members[0]),
|
||||
});
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the equality key that decides which tags share a query. Both models produce a 5-tuple whose
|
||||
/// first element is the model, so a key-value key can never compare equal to a wide-row key.
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="SqlTagModel.KeyValue"/> → <c>(model, table, keyColumn, valueColumn, timestampColumn)</c>.
|
||||
/// Every one of those shapes the emitted SELECT: sharing a plan across two <c>valueColumn</c>s
|
||||
/// would make one tag publish the other's column.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="SqlTagModel.WideRow"/> → <c>(model, table, whereColumn, whereValue, topByTimestamp)</c>
|
||||
/// — i.e. the table plus the whole row selector, exactly design §5.3's
|
||||
/// <c>(table, rowSelector)</c>. The members' own <c>columnName</c>/<c>timestampColumn</c> are
|
||||
/// deliberately <b>not</b> in the key: differing there is the point of the model (many columns,
|
||||
/// one row), and they are added to the SELECT list instead.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>String comparison is <b>ordinal</b>. SQL Server object names are usually case-insensitive, so
|
||||
/// two tags authored <c>dbo.T</c> and <c>DBO.T</c> plan as two groups. That costs one extra round-trip
|
||||
/// and is never wrong; guessing the server's collation here could fold two genuinely different sources.</para>
|
||||
/// </summary>
|
||||
private static object BuildGroupKey(SqlTagDefinition tag) => tag.Model switch
|
||||
{
|
||||
SqlTagModel.KeyValue => (tag.Model, tag.Table, tag.KeyColumn, tag.ValueColumn, tag.TimestampColumn),
|
||||
SqlTagModel.WideRow => (tag.Model, tag.Table, tag.RowSelectorColumn, tag.RowSelectorValue,
|
||||
tag.RowSelectorTopByTimestamp),
|
||||
_ => throw UnsupportedModel(tag),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN)</c>.
|
||||
/// The key column is selected because the reader indexes rows by it to slice values back per member.
|
||||
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
|
||||
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
|
||||
/// </summary>
|
||||
private static SqlQueryPlan BuildKeyValuePlan(
|
||||
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
|
||||
{
|
||||
var first = members[0];
|
||||
var keyColumn = RequireIdentifier(first.KeyColumn, nameof(SqlTagDefinition.KeyColumn), first);
|
||||
var valueColumn = RequireIdentifier(first.ValueColumn, nameof(SqlTagDefinition.ValueColumn), first);
|
||||
var timestampColumn = Blank(first.TimestampColumn) ? null : first.TimestampColumn;
|
||||
|
||||
var selected = new List<string>(3);
|
||||
AddDistinct(selected, keyColumn);
|
||||
AddDistinct(selected, valueColumn);
|
||||
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
|
||||
|
||||
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
|
||||
var names = new List<string>(members.Count);
|
||||
var values = new List<object?>(members.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
{
|
||||
var keyValue = member.KeyValue
|
||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||
if (!seen.Add(keyValue)) continue;
|
||||
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
|
||||
values.Add(keyValue);
|
||||
}
|
||||
|
||||
var sql = new StringBuilder("SELECT ")
|
||||
.Append(QuoteList(selected, dialect))
|
||||
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
|
||||
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
|
||||
.Append(" IN (").AppendJoin(", ", names).Append(')')
|
||||
.ToString();
|
||||
|
||||
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <cols> FROM <table> WHERE <whereColumn> = @w</c>, or — for a
|
||||
/// <c>topByTimestamp</c> selector — the single-row form
|
||||
/// <c>SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit></c>, whose
|
||||
/// row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
|
||||
/// <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
|
||||
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
|
||||
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
|
||||
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
|
||||
/// row in the result already matches the bound value, so reading it back adds nothing.</para>
|
||||
/// </summary>
|
||||
private static SqlQueryPlan BuildWideRowPlan(
|
||||
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
|
||||
{
|
||||
var first = members[0];
|
||||
|
||||
var selected = new List<string>(members.Count + 1);
|
||||
foreach (var member in members)
|
||||
AddDistinct(selected, RequireIdentifier(member.ColumnName, nameof(SqlTagDefinition.ColumnName), member));
|
||||
foreach (var member in members)
|
||||
if (!Blank(member.TimestampColumn))
|
||||
AddDistinct(selected, member.TimestampColumn!);
|
||||
|
||||
var table = QuoteQualifiedName(first.Table, dialect);
|
||||
var columns = QuoteList(selected, dialect);
|
||||
|
||||
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
|
||||
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
|
||||
{
|
||||
var sql = string.Concat(
|
||||
"SELECT ", columns, " FROM ", table,
|
||||
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
|
||||
return new SqlQueryPlan(
|
||||
SqlTagModel.WideRow, groupKey, sql,
|
||||
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
|
||||
}
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
{
|
||||
// The row limit is dialect syntax and sits at opposite ends of the statement per provider
|
||||
// (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted
|
||||
// unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix.
|
||||
var sql = string.Concat(
|
||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||
dialect.SingleRowLimitSuffix);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Wide-row tag '{first.Name}' has no row selector: author either a rowSelector.whereColumn/" +
|
||||
"whereValue pair or a rowSelector.topByTimestamp column.", nameof(members));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a possibly multi-part object name by splitting on <c>.</c> and quoting each part —
|
||||
/// <c>dbo.TagValues</c> becomes <c>[dbo].[TagValues]</c>.
|
||||
/// <para><see cref="ISqlDialect.QuoteIdentifier"/> quotes exactly <b>one</b> part; handing it the dotted
|
||||
/// form would yield <c>[dbo.TagValues]</c>, which is safe but names an object that does not exist. An
|
||||
/// empty part (<c>dbo..T</c>) is rejected by the dialect rather than emitted. As a consequence an object
|
||||
/// whose real name contains a literal <c>.</c> cannot be authored — an accepted v1 limitation.</para>
|
||||
/// </summary>
|
||||
private static string QuoteQualifiedName(string name, ISqlDialect dialect)
|
||||
{
|
||||
if (Blank(name))
|
||||
throw new ArgumentException("A Sql tag's 'table' must be a non-empty object name.", nameof(name));
|
||||
|
||||
return string.Join('.', name.Split('.').Select(dialect.QuoteIdentifier));
|
||||
}
|
||||
|
||||
/// <summary>Quotes each already-de-duplicated column name and joins them into a SELECT list.</summary>
|
||||
private static string QuoteList(IReadOnlyList<string> columns, ISqlDialect dialect)
|
||||
=> string.Join(", ", columns.Select(dialect.QuoteIdentifier));
|
||||
|
||||
/// <summary>Appends <paramref name="value"/> unless an ordinal-equal entry is already present.</summary>
|
||||
private static void AddDistinct(List<string> target, string value)
|
||||
{
|
||||
if (!target.Contains(value, StringComparer.Ordinal)) target.Add(value);
|
||||
}
|
||||
|
||||
/// <summary>True when the string is null, empty, or all whitespace — i.e. cannot be an identifier.</summary>
|
||||
private static bool Blank(string? value) => string.IsNullOrWhiteSpace(value);
|
||||
|
||||
/// <summary>Returns a required identifier field, or throws naming both the tag and the field.</summary>
|
||||
private static string RequireIdentifier(string? value, string field, SqlTagDefinition tag)
|
||||
=> Blank(value) ? throw MissingField(field, tag) : value!;
|
||||
|
||||
private static ArgumentException MissingField(string field, SqlTagDefinition tag)
|
||||
=> new($"Sql tag '{tag.Name}' ({tag.Model}) is missing the required '{field}'.");
|
||||
|
||||
private static NotSupportedException UnsupportedModel(SqlTagDefinition tag)
|
||||
=> new($"Sql tag '{tag.Name}' uses the {tag.Model} model, which the poll planner does not support. " +
|
||||
"v1 plans KeyValue and WideRow only; the named-query model is deferred (design §5.4).");
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Executes one poll pass: resolves each requested reference to a <see cref="SqlTagDefinition"/>, folds
|
||||
/// the definitions into the minimum number of queries via <see cref="SqlGroupPlanner"/>, runs one
|
||||
/// command per group under a bounded deadline, and slices each result set back to per-tag
|
||||
/// <see cref="DataValueSnapshot"/>s — <b>positionally aligned with the caller's reference list</b>
|
||||
/// (design §3.2). Structurally the SQL analogue of <c>ModbusDriver.ReadCoalescedAsync</c>: group → one
|
||||
/// round-trip → slice back.
|
||||
/// <para><b>N in, N out, in order.</b> The returned list always has exactly one entry per requested
|
||||
/// reference, at the same index, whatever happened. Per-tag failures are Bad-coded snapshots, never
|
||||
/// exceptions (the <see cref="IReadable"/> contract) — see <see cref="SqlStatusCodes"/> for which code
|
||||
/// means what. This matters more here than in a point-to-point driver: one result set feeds many tags,
|
||||
/// so a slicing defect does not fail loudly — it publishes <em>one tag's value onto another tag's
|
||||
/// node</em>.</para>
|
||||
/// <para><b>The whole call throws only when the database is unreachable</b> — that is, when opening a
|
||||
/// connection fails. That surfaces to <see cref="PollGroupEngine"/> as a poll failure and earns the
|
||||
/// capped-exponential backoff; the driver's <c>IHostConnectivityProbe</c> is what scopes Bad quality
|
||||
/// across the subtree during an outage, so nothing is lost by not manufacturing per-tag snapshots for
|
||||
/// an outage. A query that fails <em>after</em> the connection opened is the group's problem, not the
|
||||
/// server's, and Bad-codes that group only.</para>
|
||||
/// <para><b>Deadlines (design §8.3, the R2-01 frozen-peer lesson).</b> Two independent mechanisms, both
|
||||
/// required. <c>CommandTimeout</c> is the <em>server-side</em> backstop and bounds nothing on a wedged
|
||||
/// socket. The wall-clock bound is client-side and is what actually guarantees this method returns: see
|
||||
/// <see cref="RunGroupAsync"/>. A frozen database yields <see cref="SqlStatusCodes.BadTimeout"/>
|
||||
/// snapshots — it must never wedge the poll thread.</para>
|
||||
/// <para><b>Not a driver.</b> This type owns the read path only; it deliberately holds no health state,
|
||||
/// no subscription state and no connection between polls, so the driver shell can wrap it in
|
||||
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlPollReader
|
||||
{
|
||||
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
|
||||
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly DbProviderFactory _factory;
|
||||
private readonly string _connectionString;
|
||||
private readonly ISqlDialect _dialect;
|
||||
private readonly TimeSpan _operationTimeout;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
private readonly bool _nullIsBad;
|
||||
private readonly Func<string, SqlTagDefinition?> _resolve;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Caps concurrently executing group queries — and therefore concurrently held connections
|
||||
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
|
||||
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
|
||||
/// timed-out-but-still-running group releasing into a disposed semaphore.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate;
|
||||
|
||||
private long _lastContractWarningTicks;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
|
||||
/// <param name="factory">
|
||||
/// Creates the provider's connections. Passed explicitly rather than taken from
|
||||
/// <paramref name="dialect"/> so the driver can hand in an instrumented or pre-configured factory.
|
||||
/// </param>
|
||||
/// <param name="connectionString">
|
||||
/// The resolved connection string (never the authored <c>connectionStringRef</c> — secrets are
|
||||
/// resolved by the driver at Initialize, design §8.2).
|
||||
/// </param>
|
||||
/// <param name="dialect">Supplies identifier quoting, row-limit syntax, and column-type mapping.</param>
|
||||
/// <param name="commandTimeout">
|
||||
/// The ADO.NET <c>CommandTimeout</c> backstop. Rounded <b>up</b> to whole seconds and floored at 1,
|
||||
/// because ADO.NET reads <c>CommandTimeout = 0</c> as "wait forever" — the one value this option
|
||||
/// must never silently become.
|
||||
/// </param>
|
||||
/// <param name="operationTimeout">
|
||||
/// The wall-clock ceiling on one group's whole operation — waiting for a concurrency slot, opening
|
||||
/// the connection, and running the query. A breach Bad-codes that group with
|
||||
/// <see cref="SqlStatusCodes.BadTimeout"/>.
|
||||
/// <para>Authoring should keep this strictly greater than <paramref name="commandTimeout"/>
|
||||
/// (design §8.3): inverted, the client-side abort always fires first and masks the server-side
|
||||
/// backstop. That rule is <b>not</b> enforced here — it belongs to config validation, and this type
|
||||
/// stays usable for the tests that must deliberately invert it.</para>
|
||||
/// </param>
|
||||
/// <param name="maxConcurrentGroups">
|
||||
/// Maximum group queries — and connections — in flight at once. A timed-out group keeps its slot
|
||||
/// until it truly finishes, so this is a hard ceiling on connections even against a frozen database.
|
||||
/// See <see cref="RunGroupAsync"/> for why a high value is safe under
|
||||
/// <c>Microsoft.Data.SqlClient</c> but wants sizing against thread-pool headroom under a provider
|
||||
/// that blocks synchronously.
|
||||
/// </param>
|
||||
/// <param name="nullIsBad">
|
||||
/// <see langword="true"/> publishes <see cref="SqlStatusCodes.Bad"/> for a NULL value cell instead
|
||||
/// of the default <see cref="SqlStatusCodes.Uncertain"/>. It governs a <em>present</em> row with a
|
||||
/// NULL cell only — an absent row is always <see cref="SqlStatusCodes.BadNoData"/>.
|
||||
/// </param>
|
||||
/// <param name="resolve">
|
||||
/// RawPath → tag definition; <see langword="null"/> on a miss. Shaped to match
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/>'s lookup so the driver can pass its authored table
|
||||
/// straight through.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
||||
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="connectionString"/> is blank.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">A timeout is non-positive, or the cap is below 1.</exception>
|
||||
public SqlPollReader(
|
||||
DbProviderFactory factory,
|
||||
string connectionString,
|
||||
ISqlDialect dialect,
|
||||
TimeSpan commandTimeout,
|
||||
TimeSpan operationTimeout,
|
||||
int maxConcurrentGroups,
|
||||
bool nullIsBad,
|
||||
Func<string, SqlTagDefinition?> resolve,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
throw new ArgumentException("A Sql poll reader needs a connection string.", nameof(connectionString));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(commandTimeout, TimeSpan.Zero);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operationTimeout, TimeSpan.Zero);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrentGroups, 1);
|
||||
|
||||
_factory = factory;
|
||||
_connectionString = connectionString;
|
||||
_dialect = dialect;
|
||||
_operationTimeout = operationTimeout;
|
||||
_commandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
_nullIsBad = nullIsBad;
|
||||
_resolve = resolve;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
_gate = new SemaphoreSlim(maxConcurrentGroups, maxConcurrentGroups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every reference in one poll pass. Matches <see cref="IReadable.ReadAsync"/>'s shape so the
|
||||
/// driver shell can delegate to it directly.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The RawPaths to read. Empty yields an empty result and touches no connection.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// The caller's token. Its cancellation <b>propagates</b> as an
|
||||
/// <see cref="OperationCanceledException"/> — the engine asked the poll to stop, and nobody is
|
||||
/// waiting for the snapshots. This is deliberately asymmetric with an <c>operationTimeout</c>
|
||||
/// breach, which is a data-quality fact clients must see and so becomes
|
||||
/// <see cref="SqlStatusCodes.BadTimeout"/> snapshots.
|
||||
/// </param>
|
||||
/// <returns>One snapshot per reference, at the same index.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="fullReferences"/> is null.</exception>
|
||||
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
|
||||
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
if (results.Length == 0) return results;
|
||||
|
||||
var readAt = DateTime.UtcNow;
|
||||
|
||||
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
|
||||
// whose members are the sole thing the slice-back walks.
|
||||
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
|
||||
var slots = new Dictionary<SqlTagDefinition, List<int>>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
|
||||
if (definition is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.Add(definition);
|
||||
if (!slots.TryGetValue(definition, out var positions))
|
||||
slots[definition] = positions = [];
|
||||
positions.Add(i);
|
||||
}
|
||||
|
||||
if (definitions.Count > 0)
|
||||
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
|
||||
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
|
||||
private async Task ReadGroupsAsync(
|
||||
List<SqlTagDefinition> definitions,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SqlQueryPlan> plans;
|
||||
try
|
||||
{
|
||||
plans = SqlGroupPlanner.Plan(definitions, _dialect);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
|
||||
// reaching here means a definition was built past the parser. It is an authoring fault, not a
|
||||
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
|
||||
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
|
||||
definitions.Count);
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
if (!slots.TryGetValue(definition, out var positions)) continue;
|
||||
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
|
||||
// carries its own deadline, so the whole call is bounded by ~operationTimeout rather than by
|
||||
// plans.Count × operationTimeout.
|
||||
var groups = new Task<GroupResult>[plans.Count];
|
||||
for (var g = 0; g < plans.Count; g++)
|
||||
groups[g] = RunGroupAsync(plans[g], cancellationToken);
|
||||
|
||||
// WhenAll waits for every group before surfacing a fault, so a thrown "database unreachable" never
|
||||
// leaves a sibling group running against a connection nobody is watching.
|
||||
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
|
||||
|
||||
for (var g = 0; g < plans.Count; g++)
|
||||
Slice(plans[g], outcomes[g], slots, results, readAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one group's query under a hard wall-clock ceiling.
|
||||
/// <para><b>Why the ceiling is <c>Task.WaitAsync</c> and not just a linked
|
||||
/// <c>CancelAfter</c>.</b> A linked token only bounds a
|
||||
/// provider that <em>honours</em> it. The S7 R2-01 finding was exactly a provider whose async path
|
||||
/// ignored its deadline, and ADO.NET has the same shape: some providers implement
|
||||
/// <c>ExecuteReaderAsync</c> synchronously, and even a genuinely async one can hang inside its own
|
||||
/// cancellation handshake against a frozen socket. Both are used here: the linked token so a
|
||||
/// well-behaved provider unwinds cleanly and releases its connection, and <c>WaitAsync</c> so
|
||||
/// control returns at the deadline regardless.</para>
|
||||
/// <para>The work is started on the thread pool so a provider that blocks synchronously blocks a
|
||||
/// pool thread rather than the caller — without that, a synchronous provider never yields the task
|
||||
/// there would be to bound.</para>
|
||||
/// <para><b>Thread-pool scheduling and what a BadTimeout therefore means.</b> The group's clock
|
||||
/// starts before <c>Task.Run</c>, so in principle a group could burn budget queueing for a pool
|
||||
/// thread rather than waiting on the database. <c>TaskCreationOptions.LongRunning</c> (a dedicated
|
||||
/// OS thread) was considered and <b>rejected</b>: it would create and tear down one thread per group
|
||||
/// per poll, forever, on a driver whose whole job is to poll on a short cadence — a permanent cost
|
||||
/// paid against a hazard that cannot arise with the provider this driver actually ships.
|
||||
/// <c>Microsoft.Data.SqlClient</c>'s async path is genuinely asynchronous: the delegate reaches its
|
||||
/// first real await within microseconds and returns the pool thread, so a frozen SQL Server parks
|
||||
/// <em>zero</em> pool threads however long it stays frozen, and the driver cannot starve itself no
|
||||
/// matter how high <c>maxConcurrentGroups</c> goes. The queueing scenario needs a provider that
|
||||
/// degrades to synchronous blocking — which is exactly what the SQLite test rig exploits on purpose,
|
||||
/// and is not a shipped configuration. <b>Consequence for outage diagnosis (e.g. blackhole-testing a
|
||||
/// paused SQL Server):</b> under <c>Microsoft.Data.SqlClient</c> a
|
||||
/// <see cref="SqlStatusCodes.BadTimeout"/> means the database did not answer inside
|
||||
/// <c>operationTimeout</c> — it cannot be an artefact of this driver's own pool pressure. Under a
|
||||
/// synchronously-blocking provider it may also include queueing delay, so size
|
||||
/// <c>maxConcurrentGroups</c> below the process's readily-available worker count there.</para>
|
||||
/// <para><b>The concurrency slot is released by the work, not by the waiter.</b> A timed-out group
|
||||
/// keeps its slot until it truly finishes, so a frozen database can never accumulate more than
|
||||
/// <c>maxConcurrentGroups</c> connections however long it stays frozen. The slot wait is itself
|
||||
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
|
||||
/// </summary>
|
||||
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
if (!await _gate.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false))
|
||||
return GroupResult.TimedOut;
|
||||
|
||||
var handedOff = false;
|
||||
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
Task<GroupResult> work;
|
||||
try
|
||||
{
|
||||
var budget = Remaining(clock);
|
||||
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
work = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await QueryAsync(plan, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
deadline.Dispose();
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
handedOff = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Ownership of both the slot and the CTS transfers to the work task; release them here only on
|
||||
// the paths where that task was never created.
|
||||
if (!handedOff)
|
||||
{
|
||||
_gate.Release();
|
||||
deadline.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await work.WaitAsync(Remaining(clock), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Our wall-clock bound fired while the provider was still inside the call.
|
||||
return TimedOut(plan, work);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Either the caller pulled the plug, or our deadline fired and the provider DID honour the
|
||||
// linked token. Both leave the work running with nobody to observe its fault.
|
||||
if (!cancellationToken.IsCancellationRequested) return TimedOut(plan, work);
|
||||
Detach(work);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
|
||||
private TimeSpan Remaining(Stopwatch clock)
|
||||
{
|
||||
var remaining = _operationTimeout - clock.Elapsed;
|
||||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>Records the deadline breach and detaches the abandoned work.</summary>
|
||||
private GroupResult TimedOut(SqlQueryPlan plan, Task work)
|
||||
{
|
||||
// The table is named for the same reason the contract warnings name it: under a partial outage this
|
||||
// line repeats every poll, and "which source is frozen" is the only question the operator has.
|
||||
_logger.LogWarning(
|
||||
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
|
||||
"timeout; publishing BadTimeout.",
|
||||
plan.Model, plan.Members[0].Table, plan.Members.Count,
|
||||
(int)_operationTimeout.TotalMilliseconds);
|
||||
|
||||
Detach(work);
|
||||
return GroupResult.TimedOut;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes an abandoned group's eventual fault so it never surfaces as an unobserved task
|
||||
/// exception. The task still owns its connection and still releases the concurrency slot when it
|
||||
/// finally completes — that is what keeps a frozen database from accumulating connections.
|
||||
/// </summary>
|
||||
private static void Detach(Task work)
|
||||
=> _ = work.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a connection, executes the plan's single command, and materialises the result set.
|
||||
/// <para>The rows are read into memory <b>before</b> the reader is disposed — the slice-back needs
|
||||
/// random access across members, and holding a reader (and therefore a connection) open across that
|
||||
/// work is exactly how a poll loop exhausts a pool.</para>
|
||||
/// </summary>
|
||||
private async Task<GroupResult> QueryAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"The {_dialect.Provider} provider factory returned no connection.");
|
||||
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = _connectionString;
|
||||
|
||||
// Deliberately OUTSIDE the catch below: a failed open means the database is unreachable, which
|
||||
// is the one condition IReadable says the whole call may throw on (→ PollGroupEngine backoff).
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = plan.SqlText;
|
||||
command.CommandTimeout = _commandTimeoutSeconds;
|
||||
for (var i = 0; i < plan.ParameterNames.Count; i++)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
|
||||
parameter.ParameterName = plan.ParameterNames[i];
|
||||
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (reader.ConfigureAwait(false))
|
||||
{
|
||||
return await MaterialiseAsync(plan, reader, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The connection opened, so the server is up — this query is what failed (an identifier that
|
||||
// is not in the catalog, a lock, a permission). That is this group's problem alone.
|
||||
_logger.LogWarning(
|
||||
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
|
||||
plan.Model, plan.Members.Count, plan.SqlText);
|
||||
return GroupResult.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every row into memory, projected onto <see cref="SqlQueryPlan.SelectedColumns"/>. Row
|
||||
/// fetching stays on the async path so a result set that streams slowly still honours the deadline
|
||||
/// token mid-read, rather than only at the ExecuteReader boundary.
|
||||
/// </summary>
|
||||
private async Task<GroupResult> MaterialiseAsync(
|
||||
SqlQueryPlan plan, DbDataReader reader, CancellationToken cancellationToken)
|
||||
{
|
||||
var selected = plan.SelectedColumns;
|
||||
var ordinals = new int[selected.Count];
|
||||
var typeNames = new string[selected.Count];
|
||||
var columnIndex = new Dictionary<string, int>(selected.Count, StringComparer.Ordinal);
|
||||
for (var c = 0; c < selected.Count; c++)
|
||||
{
|
||||
// GetOrdinal rather than the position in SelectedColumns: the list is documented to be in
|
||||
// ordinal order, but resolving it against the live result set is what makes a wrong slice
|
||||
// impossible rather than merely unlikely.
|
||||
ordinals[c] = reader.GetOrdinal(selected[c]);
|
||||
typeNames[c] = SafeTypeName(reader, ordinals[c]);
|
||||
columnIndex[selected[c]] = c;
|
||||
}
|
||||
|
||||
var rows = new List<object?[]>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = new object?[selected.Count];
|
||||
for (var c = 0; c < selected.Count; c++)
|
||||
row[c] = reader.IsDBNull(ordinals[c]) ? null : reader.GetValue(ordinals[c]);
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return GroupResult.Ok(rows, columnIndex, typeNames);
|
||||
}
|
||||
|
||||
/// <summary>Reads a column's provider type name, falling back to the dialect's unknown-type default.</summary>
|
||||
private static string SafeTypeName(DbDataReader reader, int ordinal)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reader.GetDataTypeName(ordinal) ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException
|
||||
or IndexOutOfRangeException)
|
||||
{
|
||||
// A provider that declines to report a type name must not fail the poll — the tag's declared
|
||||
// type (or the dialect's String fallback) covers it.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
|
||||
private void Slice(
|
||||
SqlQueryPlan plan,
|
||||
GroupResult outcome,
|
||||
Dictionary<SqlTagDefinition, List<int>> slots,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime readAt)
|
||||
{
|
||||
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
|
||||
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
|
||||
// they are group-wide facts, and doing them per member would report a contract violation N times.
|
||||
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
|
||||
? IndexByKey(plan, outcome)
|
||||
: null;
|
||||
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
|
||||
? SelectWideRow(plan, outcome)
|
||||
: null;
|
||||
|
||||
foreach (var member in plan.Members)
|
||||
{
|
||||
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
|
||||
if (!slots.TryGetValue(member, out var positions)) continue;
|
||||
|
||||
var snapshot = outcome.Succeeded
|
||||
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
|
||||
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
|
||||
|
||||
foreach (var position in positions) results[position] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
|
||||
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
|
||||
/// violation is reported through a rate-limited warning rather than silently.
|
||||
/// </summary>
|
||||
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
|
||||
{
|
||||
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
|
||||
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
|
||||
return index;
|
||||
|
||||
var duplicated = 0;
|
||||
foreach (var row in outcome.Rows)
|
||||
{
|
||||
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
|
||||
// matched by a tag whose keyValue is legitimately the empty string.
|
||||
if (row[keyAt] is not { } keyCell) continue;
|
||||
|
||||
// Stringified because the bound keyValue is authored text while the column may be any type
|
||||
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
|
||||
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
|
||||
if (key is null) continue;
|
||||
if (!index.TryAdd(key, row))
|
||||
{
|
||||
index[key] = row;
|
||||
duplicated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicated > 0 && ShouldWarnAboutContract())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
|
||||
"last row wins. The key-value model requires one row per key — point the tag at a " +
|
||||
"current-values table or an operator-authored latest-per-key view.",
|
||||
plan.Members[0].Table, duplicated);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the single row every member of a wide-row plan reads, and reports an ambiguous selector.
|
||||
/// <para><b>Last row wins</b> — the same tie-break <see cref="IndexByKey"/> applies, so both models
|
||||
/// degrade the same way. It is deterministic only <em>within one result set</em>: the where-pair form
|
||||
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
|
||||
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
|
||||
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
|
||||
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
|
||||
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
|
||||
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
|
||||
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
|
||||
/// (<c>Rows.Count > 1</c>), converting a loud misconfiguration into a permanently silent one —
|
||||
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
|
||||
/// form keeps its row limit because there the <c>ORDER BY</c> makes "the first row" a defined
|
||||
/// answer. The accepted cost is that an ambiguous selector materialises every matching row for one
|
||||
/// poll; the warning is what gets it fixed.</para>
|
||||
/// </summary>
|
||||
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
|
||||
{
|
||||
if (outcome.Rows.Count == 0) return null;
|
||||
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
|
||||
{
|
||||
var first = plan.Members[0];
|
||||
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
|
||||
? string.Concat("topByTimestamp ", first.RowSelectorTopByTimestamp)
|
||||
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
|
||||
_logger.LogWarning(
|
||||
"Sql poll source '{Table}' returned {Rows} rows for the wide-row selector '{Selector}' in " +
|
||||
"one poll; the last row read wins, and the query carries no ORDER BY, so which row that is " +
|
||||
"depends on storage order. The wide-row model requires a selector matching exactly one row " +
|
||||
"— narrow the whereColumn/whereValue pair, or point the tag at a latest-row view.",
|
||||
first.Table, outcome.Rows.Count, selector);
|
||||
}
|
||||
|
||||
return outcome.Rows[^1];
|
||||
}
|
||||
|
||||
/// <summary>Maps one member's cell to a snapshot: value, quality, and source timestamp.</summary>
|
||||
private DataValueSnapshot MapMember(
|
||||
SqlQueryPlan plan,
|
||||
SqlTagDefinition member,
|
||||
GroupResult outcome,
|
||||
Dictionary<string, object?[]>? byKey,
|
||||
object?[]? wideRow,
|
||||
DateTime readAt)
|
||||
{
|
||||
object?[]? row;
|
||||
string? valueColumn;
|
||||
string? timestampColumn;
|
||||
if (plan.Model == SqlTagModel.KeyValue)
|
||||
{
|
||||
valueColumn = plan.ValueColumn;
|
||||
timestampColumn = plan.TimestampColumn;
|
||||
row = member.KeyValue is { } key && byKey is not null ? byKey.GetValueOrDefault(key) : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
valueColumn = member.ColumnName;
|
||||
// A wide-row plan's members may legitimately carry DIFFERENT timestamp columns (all of them are
|
||||
// selected), which is why the plan-level TimestampColumn is null for this model by design.
|
||||
timestampColumn = member.TimestampColumn;
|
||||
row = wideRow;
|
||||
}
|
||||
|
||||
// No row: the key was deleted, or the row selector matched nothing. Distinct from a NULL cell.
|
||||
if (row is null) return new DataValueSnapshot(null, SqlStatusCodes.BadNoData, null, readAt);
|
||||
|
||||
// A timestamp that will not parse falls back to the poll clock rather than poisoning a good value.
|
||||
var sourceTimestamp = ReadTimestamp(outcome, row, timestampColumn) ?? readAt;
|
||||
|
||||
if (valueColumn is null || !outcome.ColumnIndex.TryGetValue(valueColumn, out var valueAt))
|
||||
{
|
||||
// The planner selects every member's value column, so this is unreachable short of plan/result
|
||||
// drift — loud rather than silently publishing someone else's cell.
|
||||
_logger.LogError(
|
||||
"Sql tag '{Tag}' reads column '{Column}', which the executed plan did not select.",
|
||||
member.Name, valueColumn);
|
||||
return new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
var cell = row[valueAt];
|
||||
if (cell is null)
|
||||
{
|
||||
return new DataValueSnapshot(
|
||||
null, _nullIsBad ? SqlStatusCodes.Bad : SqlStatusCodes.Uncertain, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
|
||||
if (!TryCoerce(cell, target, out var value))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
|
||||
member.Name, cell.GetType().Name, target);
|
||||
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
return new DataValueSnapshot(value, SqlStatusCodes.Good, sourceTimestamp, readAt);
|
||||
}
|
||||
|
||||
/// <summary>Reads a row's source timestamp, or null when there is no column, no value, or no parse.</summary>
|
||||
private static DateTime? ReadTimestamp(GroupResult outcome, object?[] row, string? timestampColumn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timestampColumn)) return null;
|
||||
if (!outcome.ColumnIndex.TryGetValue(timestampColumn, out var at)) return null;
|
||||
if (row[at] is not { } cell) return null;
|
||||
return TryCoerce(cell, DriverDataType.DateTime, out var value) ? (DateTime?)value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a provider cell to the tag's effective <see cref="DriverDataType"/>, so the published CLR
|
||||
/// type matches the type the address space declared for the node. Never throws.
|
||||
/// </summary>
|
||||
private static bool TryCoerce(object cell, DriverDataType target, out object? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
value = target switch
|
||||
{
|
||||
DriverDataType.Boolean => Convert.ToBoolean(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.Int16 => Convert.ToInt16(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.Int32 => Convert.ToInt32(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.Int64 => Convert.ToInt64(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.UInt16 => Convert.ToUInt16(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.UInt32 => Convert.ToUInt32(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.UInt64 => Convert.ToUInt64(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.Float32 => Convert.ToSingle(cell, CultureInfo.InvariantCulture),
|
||||
// decimal/numeric collapse here, with the documented precision caveat (design §8.6).
|
||||
DriverDataType.Float64 => Convert.ToDouble(cell, CultureInfo.InvariantCulture),
|
||||
DriverDataType.DateTime => ToUtc(cell),
|
||||
_ => Convert.ToString(cell, CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException
|
||||
or OverflowException or ArgumentException)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a timestamp cell to UTC. A naive <c>datetime</c> (no offset — the common SQL Server
|
||||
/// shape) is <b>assumed</b> to already be UTC rather than reinterpreted through the server's local
|
||||
/// zone, which would silently shift every source timestamp by the host's offset.
|
||||
/// </summary>
|
||||
private static DateTime ToUtc(object cell) => cell switch
|
||||
{
|
||||
DateTime { Kind: DateTimeKind.Utc } utc => utc,
|
||||
DateTime { Kind: DateTimeKind.Local } local => local.ToUniversalTime(),
|
||||
DateTime unspecified => DateTime.SpecifyKind(unspecified, DateTimeKind.Utc),
|
||||
DateTimeOffset offset => offset.UtcDateTime,
|
||||
string text => DateTime.Parse(
|
||||
text, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
|
||||
_ => DateTime.SpecifyKind(
|
||||
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
|
||||
private bool ShouldWarnAboutContract()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var last = Interlocked.Read(ref _lastContractWarningTicks);
|
||||
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
|
||||
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
|
||||
}
|
||||
|
||||
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
|
||||
private sealed class GroupResult
|
||||
{
|
||||
private static readonly IReadOnlyList<object?[]> NoRows = [];
|
||||
private static readonly IReadOnlyDictionary<string, int> NoColumns =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
private GroupResult(
|
||||
uint failureStatus,
|
||||
IReadOnlyList<object?[]> rows,
|
||||
IReadOnlyDictionary<string, int> columnIndex,
|
||||
IReadOnlyList<string> typeNames)
|
||||
{
|
||||
FailureStatus = failureStatus;
|
||||
Rows = rows;
|
||||
ColumnIndex = columnIndex;
|
||||
TypeNames = typeNames;
|
||||
}
|
||||
|
||||
/// <summary>The group exceeded its wall-clock deadline (design §8.3).</summary>
|
||||
public static GroupResult TimedOut { get; } =
|
||||
new(SqlStatusCodes.BadTimeout, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The connection was fine but the query was not.</summary>
|
||||
public static GroupResult Failed { get; } =
|
||||
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
|
||||
|
||||
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
|
||||
public uint FailureStatus { get; }
|
||||
|
||||
/// <summary>The materialised rows, projected onto the plan's selected columns.</summary>
|
||||
public IReadOnlyList<object?[]> Rows { get; }
|
||||
|
||||
/// <summary>Selected column name → its position within each row array.</summary>
|
||||
public IReadOnlyDictionary<string, int> ColumnIndex { get; }
|
||||
|
||||
/// <summary>Each selected column's provider type name, for dialect type inference.</summary>
|
||||
public IReadOnlyList<string> TypeNames { get; }
|
||||
|
||||
/// <summary>True when the query ran; a zero-row result set is still a success.</summary>
|
||||
public bool Succeeded => FailureStatus == SqlStatusCodes.Good;
|
||||
|
||||
/// <summary>A completed result set.</summary>
|
||||
/// <param name="rows">The materialised rows.</param>
|
||||
/// <param name="columnIndex">Selected column name → row-array position.</param>
|
||||
/// <param name="typeNames">Each selected column's provider type name.</param>
|
||||
/// <returns>A successful outcome.</returns>
|
||||
public static GroupResult Ok(
|
||||
IReadOnlyList<object?[]> rows,
|
||||
IReadOnlyDictionary<string, int> columnIndex,
|
||||
IReadOnlyList<string> typeNames)
|
||||
=> new(SqlStatusCodes.Good, rows, columnIndex, typeNames);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
|
||||
/// them back with.
|
||||
/// <para><b>Why a driver-local table rather than a shared one.</b> Every driver in this tree carries its
|
||||
/// own (<c>TwinCATStatusMapper</c>, <c>AbCipStatusMapper</c>, <c>FocasStatusMapper</c>,
|
||||
/// <c>AbLegacyStatusMapper</c>, Galaxy's <c>StatusCodeMap</c>) — there is no shared helper in
|
||||
/// <c>Core.Abstractions</c>, because drivers deliberately do not reference the OPC UA SDK and
|
||||
/// <see cref="DataValueSnapshot.StatusCode"/> is a bare <see cref="uint"/>. The values below were read
|
||||
/// off <c>Opc.Ua.StatusCodes</c> rather than copied from a sibling driver, since two of those tables
|
||||
/// carry transcription errors.</para>
|
||||
/// </summary>
|
||||
public static class SqlStatusCodes
|
||||
{
|
||||
/// <summary>The value is good. (<c>Good</c>)</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>Quality is uncertain with no more specific reason — a NULL cell under <c>nullIsBad=false</c>.</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>Quality is bad with no more specific reason — a NULL cell under <c>nullIsBad=true</c>.</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
|
||||
/// <summary>
|
||||
/// No row was returned for this tag — the key is absent from the result set, or the wide-row
|
||||
/// selector matched nothing. <b>Deliberately distinct from a NULL cell:</b> the row is gone, versus
|
||||
/// the row is there and the value is not.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
|
||||
/// <summary>The group exceeded its client-side wall-clock deadline (design §8.3).</summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
|
||||
/// <summary>The reference resolves to no authored tag.</summary>
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>The query failed after the connection opened.</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>The cell could not be coerced to the tag's effective data type.</summary>
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>A tag definition the planner rejected — an authoring fault, not a database fault.</summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>A defect in the reader itself; never expected in the field.</summary>
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
|
||||
/// <summary>The quality-class mask — the top two bits of an OPC UA status code.</summary>
|
||||
private const uint SeverityMask = 0xC0000000u;
|
||||
|
||||
/// <summary>True when <paramref name="statusCode"/> is in the Good quality class.</summary>
|
||||
/// <param name="statusCode">The status code to classify.</param>
|
||||
/// <returns>True when the code's severity bits are Good.</returns>
|
||||
public static bool IsGood(uint statusCode) => (statusCode & SeverityMask) == 0u;
|
||||
|
||||
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain quality class.</summary>
|
||||
/// <param name="statusCode">The status code to classify.</param>
|
||||
/// <returns>True when the code's severity bits are Uncertain.</returns>
|
||||
public static bool IsUncertain(uint statusCode) => (statusCode & SeverityMask) == Uncertain;
|
||||
|
||||
/// <summary>True when <paramref name="statusCode"/> is in the Bad quality class.</summary>
|
||||
/// <param name="statusCode">The status code to classify.</param>
|
||||
/// <returns>True when the code's severity bits are Bad.</returns>
|
||||
public static bool IsBad(uint statusCode) => (statusCode & SeverityMask) >= Bad;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// One compiled poll query and the tags it feeds (design §3.6, "group execution shape"). Every tag that
|
||||
/// shares a <see cref="GroupKey"/> reads from the <b>same</b> result set, so a poll pass executes one plan
|
||||
/// per group rather than one query per tag.
|
||||
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
|
||||
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
|
||||
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
|
||||
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
|
||||
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
|
||||
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
|
||||
/// </summary>
|
||||
/// <param name="Model">
|
||||
/// The tag→value mapping model every member shares. Determines how the reader slices the result set:
|
||||
/// <see cref="SqlTagModel.KeyValue"/> indexes rows by <see cref="KeyColumn"/> and matches each member's
|
||||
/// <see cref="SqlTagDefinition.KeyValue"/>; <see cref="SqlTagModel.WideRow"/> takes the single row and
|
||||
/// reads each member's <see cref="SqlTagDefinition.ColumnName"/>.
|
||||
/// </param>
|
||||
/// <param name="GroupKey">
|
||||
/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly
|
||||
/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing.
|
||||
/// </param>
|
||||
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
|
||||
/// <param name="ParameterNames">
|
||||
/// The parameter markers appearing in <see cref="SqlText"/>, in the order they appear, aligned index-for-index
|
||||
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
|
||||
/// convention.
|
||||
/// </param>
|
||||
/// <param name="Parameters">
|
||||
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
|
||||
/// — a hostile value is inert here because it is data, not text.
|
||||
/// </param>
|
||||
/// <param name="Members">
|
||||
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
|
||||
/// may legitimately read the same cell, and both must receive a value.
|
||||
/// </param>
|
||||
/// <param name="SelectedColumns">
|
||||
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
|
||||
/// reader's map from result-set ordinal to source column.
|
||||
/// </param>
|
||||
public sealed record SqlQueryPlan(
|
||||
SqlTagModel Model,
|
||||
object GroupKey,
|
||||
string SqlText,
|
||||
IReadOnlyList<string> ParameterNames,
|
||||
IReadOnlyList<object?> Parameters,
|
||||
IReadOnlyList<SqlTagDefinition> Members,
|
||||
IReadOnlyList<string> SelectedColumns)
|
||||
{
|
||||
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
|
||||
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
|
||||
// mutation would silently corrupt every later poll that reuses the plan. Snapshot at the boundary.
|
||||
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
|
||||
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
|
||||
|
||||
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
|
||||
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
|
||||
|
||||
/// <summary>The tags this plan feeds, in input order; duplicates preserved.</summary>
|
||||
public IReadOnlyList<SqlTagDefinition> Members { get; } = [.. Members];
|
||||
|
||||
/// <summary>The bare SELECT-list column names, in result-set ordinal order.</summary>
|
||||
public IReadOnlyList<string> SelectedColumns { get; } = [.. SelectedColumns];
|
||||
|
||||
/// <summary>
|
||||
/// The key column all members are matched on — <see langword="null"/> for any model but
|
||||
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant,
|
||||
/// so <c>Members[0]</c> is authoritative.
|
||||
/// </summary>
|
||||
public string? KeyColumn => Model == SqlTagModel.KeyValue ? Members[0].KeyColumn : null;
|
||||
|
||||
/// <summary>
|
||||
/// The column every member's value is read from — <see langword="null"/> for any model but
|
||||
/// <see cref="SqlTagModel.KeyValue"/>. Uniform across <see cref="Members"/> by the group-key invariant.
|
||||
/// </summary>
|
||||
public string? ValueColumn => Model == SqlTagModel.KeyValue ? Members[0].ValueColumn : null;
|
||||
|
||||
/// <summary>
|
||||
/// The shared source-timestamp column, or <see langword="null"/> when the group has none (the reader
|
||||
/// then stamps the poll time). <see cref="SqlTagModel.KeyValue"/> only — it is part of that model's
|
||||
/// group key, so it is uniform. A <see cref="SqlTagModel.WideRow"/> plan deliberately allows members to
|
||||
/// carry <em>different</em> timestamp columns (all of them are selected), so the reader must read
|
||||
/// <see cref="SqlTagDefinition.TimestampColumn"/> per member there.
|
||||
/// </summary>
|
||||
public string? TimestampColumn => Model == SqlTagModel.KeyValue ? Members[0].TimestampColumn : null;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
|
||||
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
|
||||
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
||||
/// the AdminUI browse session.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlServerDialect : ISqlDialect
|
||||
{
|
||||
/// <summary>
|
||||
/// T-SQL's regular-identifier ceiling (<c>sysname</c> = <c>nvarchar(128)</c>). Enforced because a
|
||||
/// longer string cannot name a real catalog object — it is either a config mistake or padding, and
|
||||
/// rejecting it keeps the quoted output bounded.
|
||||
/// </summary>
|
||||
internal const int MaxIdentifierLength = 128;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SqlProvider Provider => SqlProvider.SqlServer;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => SqlClientFactory.Instance;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
|
||||
/// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
|
||||
/// separator at the call site.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => "TOP 1 ";
|
||||
|
||||
/// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
|
||||
public string SingleRowLimitSuffix => string.Empty;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
/// <summary>
|
||||
/// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
|
||||
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListTablesSql =>
|
||||
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
|
||||
"WHERE TABLE_SCHEMA = @schema ORDER BY TABLE_NAME";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListColumnsSql =>
|
||||
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table ORDER BY ORDINAL_POSITION";
|
||||
|
||||
/// <summary>
|
||||
/// Brackets one identifier part, doubling any embedded <c>]</c> — the T-SQL escape rule, and the only
|
||||
/// metacharacter that could terminate a bracketed identifier early. <c>[</c>, quotes, semicolons and
|
||||
/// comment markers are inert inside brackets and are therefore passed through unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
|
||||
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
|
||||
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
||||
/// <see cref="System.Globalization.UnicodeCategory.Format"/> character — bidi overrides, zero-width
|
||||
/// spaces, soft hyphen, BOM. Those last cannot escape the brackets, but they spoof the <em>rendered</em>
|
||||
/// text of a log line or an AdminUI label while comparing byte-different from the real catalog name
|
||||
/// (the Trojan-Source class, CVE-2021-42574) — the same log/audit-integrity concern that motivates the
|
||||
/// control-character rule.</para>
|
||||
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
||||
/// input and this exception's text reaches the driver log.</para>
|
||||
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
|
||||
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
|
||||
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
|
||||
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
|
||||
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The bracket-quoted identifier.</returns>
|
||||
/// <exception cref="ArgumentException">The value cannot be a valid SQL Server identifier.</exception>
|
||||
public string QuoteIdentifier(string ident)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ident))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
|
||||
|
||||
if (ident.Length > MaxIdentifierLength)
|
||||
throw new ArgumentException(
|
||||
$"A SQL Server identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
|
||||
nameof(ident));
|
||||
|
||||
foreach (var ch in ident)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
|
||||
|
||||
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
|
||||
// zero-width character. They are inert inside brackets but spoof rendered log/UI text.
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
|
||||
throw new ArgumentException(
|
||||
"A SQL identifier may not contain Unicode format characters "
|
||||
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
|
||||
}
|
||||
|
||||
return string.Concat("[", ident.Replace("]", "]]", StringComparison.Ordinal), "]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds an <c>INFORMATION_SCHEMA.COLUMNS.DATA_TYPE</c> family name onto a
|
||||
/// <see cref="DriverDataType"/> per design §3.7.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Precision caveat:</b> <c>decimal</c> / <c>numeric</c> / <c>money</c> / <c>smallmoney</c>
|
||||
/// collapse to <see cref="DriverDataType.Float64"/> in v1. A .NET <c>decimal</c> carries more
|
||||
/// significant digits than a <c>double</c>, so a high-precision column (e.g. <c>decimal(38,10)</c>)
|
||||
/// loses low-order digits. Authoring an explicit <c>"type": "String"</c> on the tag preserves the exact
|
||||
/// textual value when that matters.</para>
|
||||
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
|
||||
/// throws — a schema browse must render a table containing an exotic column (<c>xml</c>,
|
||||
/// <c>geography</c>, <c>hierarchyid</c>, <c>sql_variant</c>, a CLR UDT, <c>varbinary</c>) rather than
|
||||
/// crash on it. Null/blank input takes the same fallback.</para>
|
||||
/// <para><b>Deliberate non-mappings:</b> SQL Server's <c>timestamp</c>/<c>rowversion</c> is an opaque
|
||||
/// 8-byte row version, <em>not</em> a date — it falls back to <see cref="DriverDataType.String"/>
|
||||
/// rather than being mistaken for a <see cref="DriverDataType.DateTime"/>. <c>time</c> is a
|
||||
/// time-of-day span, likewise not a point in time, and takes the same fallback.</para>
|
||||
/// <para><c>tinyint</c> widens to <see cref="DriverDataType.Int16"/> (it is unsigned 0–255 in T-SQL, so
|
||||
/// a signed 8-bit type would not hold it, and the driver type set has no <c>Byte</c>).</para>
|
||||
/// </remarks>
|
||||
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
||||
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
||||
public DriverDataType MapColumnType(string sqlDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
|
||||
|
||||
return sqlDataType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bit" or "boolean" => DriverDataType.Boolean,
|
||||
"tinyint" or "smallint" => DriverDataType.Int16,
|
||||
"int" or "integer" => DriverDataType.Int32,
|
||||
"bigint" => DriverDataType.Int64,
|
||||
"real" => DriverDataType.Float32,
|
||||
"float" or "double" or "double precision"
|
||||
or "decimal" or "numeric" or "money" or "smallmoney" => DriverDataType.Float64,
|
||||
"char" or "nchar" or "varchar" or "nvarchar" or "text" or "ntext"
|
||||
or "uniqueidentifier" => DriverDataType.String,
|
||||
"date" or "datetime" or "datetime2" or "smalldatetime"
|
||||
or "datetimeoffset" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadOutOfService = 0x80BE0000u;
|
||||
public const uint BadInvalidState = 0x80350000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
public const uint BadInvalidState = 0x80AF0000u;
|
||||
|
||||
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ else
|
||||
<tr>
|
||||
<td><span class="mono small">@c.Subject</span></td>
|
||||
<td><span class="mono small">@c.Issuer</span></td>
|
||||
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
|
||||
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
|
||||
<td>@c.NotBefore.ToString("u")</td>
|
||||
<td>@c.NotAfter.ToString("u")</td>
|
||||
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
|
||||
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
|
||||
@if (_lastDeployment.SealedAtUtc is not null)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{
|
||||
<tr>
|
||||
<td><code>@Short(d.DeploymentId)</code></td>
|
||||
<td><code>@d.RevisionHash[..12]…</code></td>
|
||||
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
|
||||
<td>@d.Status</td>
|
||||
<td>@d.CreatedBy</td>
|
||||
<td>@d.CreatedAtUtc.ToString("u")</td>
|
||||
@@ -113,7 +113,7 @@
|
||||
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
|
||||
_lastMessage = result.Outcome switch
|
||||
{
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
|
||||
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
|
||||
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
|
||||
_ => result.Message ?? "Deployment rejected.",
|
||||
|
||||
@@ -38,7 +38,7 @@ else
|
||||
<span class="mono">@s.ScriptId</span>
|
||||
· <span>@s.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@s.Language</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Rendering helpers for operator-facing text that comes out of the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
|
||||
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
|
||||
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
|
||||
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
|
||||
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
|
||||
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
|
||||
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
|
||||
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
|
||||
/// <c>h1…</c>, so the display never implies more text than exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayText
|
||||
{
|
||||
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
|
||||
/// Admin UI already uses for "nothing to show" cells.</summary>
|
||||
public const string Empty = "—";
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
|
||||
/// ellipsis when (and only when) characters were dropped.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
|
||||
/// <paramref name="maxLength"/> — none of which throw.</param>
|
||||
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
|
||||
/// so a caller cannot turn a display bug into a crash.</param>
|
||||
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
|
||||
/// otherwise the truncated prefix plus an ellipsis.</returns>
|
||||
public static string Abbreviate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return Empty;
|
||||
if (maxLength < 1) maxLength = 1;
|
||||
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Sql:
|
||||
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
|
||||
+1
@@ -36,6 +36,7 @@
|
||||
<option value="Focas">Focas</option>
|
||||
<option value="OpcUaClient">OpcUaClient</option>
|
||||
<option value="GalaxyMxGateway">Galaxy</option>
|
||||
<option value="Sql">Sql</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||
</div>
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
|
||||
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
|
||||
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
|
||||
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
|
||||
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
|
||||
factory's string-typed deserialize accepts them. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Database connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Provider</label>
|
||||
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="MesStaging" />
|
||||
<div class="form-text">
|
||||
Name of the <code>Sql__ConnectionStrings__<ref></code> env var set on the driver + AdminUI hosts —
|
||||
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
|
||||
</div>
|
||||
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
|
||||
{
|
||||
<div class="text-danger small mt-1">Connection string ref is required.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Polling / timeouts *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Polling & timeouts</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Default poll interval (s)</label>
|
||||
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Operation timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Command timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max concurrent groups</label>
|
||||
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Value handling *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Value handling</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
|
||||
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
|
||||
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">
|
||||
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
|
||||
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
|
||||
// drops the composer-owned rawTags (never authored here).
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
|
||||
_form = FormModel.FromDto(dto);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
|
||||
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
|
||||
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static SqlDriverConfigDto? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
|
||||
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
|
||||
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
|
||||
public sealed class FormModel
|
||||
{
|
||||
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
|
||||
public string? ConnectionStringRef { get; set; }
|
||||
public int? DefaultPollIntervalSeconds { get; set; }
|
||||
public int? OperationTimeoutSeconds { get; set; }
|
||||
public int? CommandTimeoutSeconds { get; set; }
|
||||
public int? MaxConcurrentGroups { get; set; }
|
||||
public bool NullIsBad { get; set; }
|
||||
|
||||
public static FormModel FromDto(SqlDriverConfigDto d) => new()
|
||||
{
|
||||
Provider = d.Provider,
|
||||
ConnectionStringRef = d.ConnectionStringRef,
|
||||
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
|
||||
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
|
||||
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
|
||||
MaxConcurrentGroups = d.MaxConcurrentGroups,
|
||||
NullIsBad = d.NullIsBad ?? false,
|
||||
};
|
||||
|
||||
public SqlDriverConfigDto ToDto() => new()
|
||||
{
|
||||
Provider = Provider,
|
||||
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
|
||||
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
|
||||
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
|
||||
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
|
||||
MaxConcurrentGroups = MaxConcurrentGroups,
|
||||
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
|
||||
// and rawTags are deliberately left null so they are omitted from the authored blob.
|
||||
NullIsBad = NullIsBad ? true : null,
|
||||
};
|
||||
|
||||
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
|
||||
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
|
||||
}
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
@* Sql address picker (read-only v1):
|
||||
1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the
|
||||
table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a
|
||||
server the AdminUI can't reach, authors a tag entirely from these fields.
|
||||
2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the
|
||||
picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills
|
||||
`table`, the value/column field, and the inferred `type`.
|
||||
|
||||
The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the
|
||||
shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/
|
||||
`type` enums are matched to the parser precisely; a mismatch would author fine and never read.
|
||||
|
||||
Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the
|
||||
JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed
|
||||
TagConfig blob nor back into the driver config — there is no code path here that persists it. *@
|
||||
@implements IAsyncDisposable
|
||||
@using System.Text.Json.Nodes
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser
|
||||
@inject IBrowserSessionService BrowserService
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Model</label>
|
||||
<select class="form-select form-select-sm" @bind="_model" @bind:after="OnManualChangedAsync">
|
||||
<option value="KeyValue">KeyValue</option>
|
||||
<option value="WideRow">WideRow</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
KeyValue: one row per key. WideRow: many columns of one selected row.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Table or view</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="dbo.TagValues"
|
||||
@bind="_table" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">Schema-qualified, e.g. <code>dbo.TagValues</code>.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select form-select-sm" @bind="_type" @bind:after="OnManualChangedAsync">
|
||||
<option value="">(infer)</option>
|
||||
@foreach (var t in Enum.GetValues<DriverDataType>())
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">Blank ⇒ inferred from the column.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_model == "KeyValue")
|
||||
{
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Key column</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="tag_name"
|
||||
@bind="_keyColumn" @bind:after="OnManualChangedAsync" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Key value</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="Line1.Speed"
|
||||
@bind="_keyValue" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">Bound as a parameter — never interpolated.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Value column</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="num_value"
|
||||
@bind="_valueColumn" @bind:after="OnManualChangedAsync" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Value column</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="oven_temp"
|
||||
@bind="_columnName" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">The column this tag reads from the selected row.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Row selector</label>
|
||||
<select class="form-select form-select-sm" @bind="_selectorMode" @bind:after="OnManualChangedAsync">
|
||||
<option value="Where">Where column = value</option>
|
||||
<option value="Newest">Newest by timestamp</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_selectorMode == "Where")
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Where column</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="station_id"
|
||||
@bind="_whereColumn" @bind:after="OnManualChangedAsync" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Where value</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="7"
|
||||
@bind="_whereValue" @bind:after="OnManualChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Order-by (timestamp) column</label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
|
||||
@bind="_topByTimestamp" @bind:after="OnManualChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
|
||||
@bind="_timestampColumn" @bind:after="OnManualChangedAsync" />
|
||||
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_canOperate)
|
||||
{
|
||||
<hr class="my-3" />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label small">Ad-hoc connection string <span class="text-muted">(session-only, never saved)</span></label>
|
||||
<input type="password" class="form-control form-control-sm mono" autocomplete="off"
|
||||
placeholder="Server=…;Database=…;User Id=…;Password=…"
|
||||
@bind="_adhocConnectionString" />
|
||||
<div class="form-text">
|
||||
Leave blank to browse via the driver's <code>connectionStringRef</code> (resolved from
|
||||
the AdminUI host's <code>Sql__ConnectionStrings__<ref></code> env var). Paste a
|
||||
literal only for a one-off browse — it is used for this session and never persisted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
@if (_token == Guid.Empty)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
|
||||
@onclick="OpenBrowseAsync">
|
||||
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Browse database
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-ok">Browser open</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
|
||||
}
|
||||
</div>
|
||||
@if (_openError is not null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_openError</div>
|
||||
}
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
{
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small">Schemas → tables → columns</label>
|
||||
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
|
||||
SelectedNodeId="@_selectedColumnNodeId" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Picked column</label>
|
||||
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
|
||||
@if (_attrsLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
else if (_attrsError is not null)
|
||||
{
|
||||
<span class="text-danger small">@_attrsError</span>
|
||||
}
|
||||
else if (_attrs is null)
|
||||
{
|
||||
<span class="text-muted small">Pick a column leaf.</span>
|
||||
}
|
||||
else if (_attrs.Count == 0)
|
||||
{
|
||||
<span class="text-muted small">Column no longer present.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var a in _attrs)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center py-1"
|
||||
style="cursor:pointer" @onclick="@(() => ApplyAttributeAsync(a))">
|
||||
<span class="mono small">@a.Name</span>
|
||||
<span class="text-muted small">@a.DriverDataType · @a.SecurityClass</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-muted small">TagConfig:</span>
|
||||
<code class="mono ms-2">@(_built.Length == 0 ? "—" : _built)</code>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string CurrentAddress { get; set; } = "";
|
||||
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
|
||||
|
||||
/// <summary>Live accessor for the selected driver's <c>DriverConfig</c> JSON (carries
|
||||
/// <c>connectionStringRef</c> + <c>provider</c>). Fed to <c>BrowserService.OpenAsync("Sql", …)</c>.</summary>
|
||||
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||
|
||||
/// <summary>Fires with the picker's suggested tag display name (<c>table.column</c>) whenever a
|
||||
/// column is picked from the tree, so a host editor may default the raw tag's name. Optional —
|
||||
/// the composed TagConfig blob itself carries no display name (it is not a parser field).</summary>
|
||||
[Parameter] public EventCallback<string> SuggestedDisplayNameChanged { get; set; }
|
||||
|
||||
// Manual/composed tag fields (all editable by hand; browse only prefills a subset).
|
||||
private string _model = "KeyValue";
|
||||
private string _table = "";
|
||||
private string _keyColumn = "";
|
||||
private string _keyValue = "";
|
||||
private string _valueColumn = "";
|
||||
private string _columnName = "";
|
||||
private string _selectorMode = "Where";
|
||||
private string _whereColumn = "";
|
||||
private string _whereValue = "";
|
||||
private string _topByTimestamp = "";
|
||||
private string _timestampColumn = "";
|
||||
private string _type = "";
|
||||
|
||||
// Browse-only, session-scoped state.
|
||||
private string _adhocConnectionString = "";
|
||||
private string _built = "";
|
||||
private string _selectedColumnNodeId = "";
|
||||
private Guid _token = Guid.Empty;
|
||||
private bool _opening;
|
||||
private bool _canOperate;
|
||||
private string? _openError;
|
||||
private bool _attrsLoading;
|
||||
private string? _attrsError;
|
||||
private IReadOnlyList<AttributeInfo>? _attrs;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||
_canOperate = authResult.Succeeded;
|
||||
_built = Build();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private async Task OpenBrowseAsync()
|
||||
{
|
||||
_opening = true;
|
||||
_openError = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
// Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is
|
||||
// never written to the composed TagConfig blob nor back to the driver config — this is the
|
||||
// sole place it is read, and it dies with the local variable.
|
||||
var json = BuildBrowseConfig();
|
||||
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
|
||||
if (result.Ok) { _token = result.Token; }
|
||||
else { _openError = result.Message; }
|
||||
}
|
||||
finally { _opening = false; StateHasChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one,
|
||||
/// a top-level <c>connectionString</c> literal. The literal wins over any <c>connectionStringRef</c>
|
||||
/// server-side (SqlDriverBrowser precedence) and is session-only.
|
||||
/// </summary>
|
||||
private string BuildBrowseConfig()
|
||||
{
|
||||
var baseJson = GetConfigJson() ?? "{}";
|
||||
if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; }
|
||||
|
||||
JsonObject o;
|
||||
try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); }
|
||||
catch (System.Text.Json.JsonException) { o = new JsonObject(); }
|
||||
o["connectionString"] = _adhocConnectionString;
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
private async Task CloseBrowseAsync()
|
||||
{
|
||||
var t = _token;
|
||||
_token = Guid.Empty;
|
||||
_attrs = null;
|
||||
_selectedColumnNodeId = "";
|
||||
StateHasChanged();
|
||||
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are
|
||||
/// navigation, not selection. The NodeId is decoded with <see cref="SqlBrowseNodeId.TryParse"/>
|
||||
/// — never split by hand, because SQL identifiers may contain '.' and '|'.
|
||||
/// </summary>
|
||||
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||
{
|
||||
if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference)
|
||||
|| reference.Kind != SqlBrowseNodeKind.Column)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedColumnNodeId = node.NodeId;
|
||||
_table = $"{reference.Schema}.{reference.Table}";
|
||||
if (_model == "WideRow") { _columnName = reference.Column!; }
|
||||
else { _valueColumn = reference.Column!; }
|
||||
|
||||
// Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified).
|
||||
await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}");
|
||||
|
||||
_attrs = null;
|
||||
_attrsLoading = true;
|
||||
_attrsError = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
_attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default);
|
||||
var picked = _attrs.Count > 0 ? _attrs[0] : null;
|
||||
if (picked is not null) { PrefillType(picked); }
|
||||
}
|
||||
catch (Exception ex) { _attrsError = ex.Message; }
|
||||
finally
|
||||
{
|
||||
_attrsLoading = false;
|
||||
await OnChangedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-applies the side-panel column's inferred type (and value-column) on click.</summary>
|
||||
private async Task ApplyAttributeAsync(AttributeInfo a)
|
||||
{
|
||||
if (_model == "WideRow") { _columnName = a.Name; }
|
||||
else { _valueColumn = a.Name; }
|
||||
PrefillType(a);
|
||||
await OnChangedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps.</summary>
|
||||
private void PrefillType(AttributeInfo a)
|
||||
{
|
||||
if (Enum.TryParse<DriverDataType>(a.DriverDataType, out _)) { _type = a.DriverDataType; }
|
||||
}
|
||||
|
||||
private async Task OnManualChangedAsync() => await OnChangedAsync();
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = Build();
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to
|
||||
/// <c>SqlEquipmentTagParser</c>. Returns "" until the selected model's required fields are all
|
||||
/// present, so the "Use this address" button never commits a half-blob the parser would reject.
|
||||
/// <c>model</c> and <c>type</c> are written as NAME strings (the parser reads them strictly).
|
||||
/// </summary>
|
||||
private string Build()
|
||||
{
|
||||
var table = _table.Trim();
|
||||
if (table.Length == 0) { return ""; }
|
||||
|
||||
var o = new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = _model,
|
||||
["table"] = table,
|
||||
};
|
||||
|
||||
if (_model == "KeyValue")
|
||||
{
|
||||
var keyColumn = _keyColumn.Trim();
|
||||
var valueColumn = _valueColumn.Trim();
|
||||
var keyValue = _keyValue.Trim();
|
||||
if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; }
|
||||
o["keyColumn"] = keyColumn;
|
||||
o["keyValue"] = keyValue;
|
||||
o["valueColumn"] = valueColumn;
|
||||
}
|
||||
else // WideRow
|
||||
{
|
||||
var columnName = _columnName.Trim();
|
||||
if (columnName.Length == 0) { return ""; }
|
||||
o["columnName"] = columnName;
|
||||
|
||||
var rowSelector = new JsonObject();
|
||||
if (_selectorMode == "Where")
|
||||
{
|
||||
var whereColumn = _whereColumn.Trim();
|
||||
var whereValue = _whereValue.Trim();
|
||||
if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; }
|
||||
rowSelector["whereColumn"] = whereColumn;
|
||||
rowSelector["whereValue"] = whereValue;
|
||||
}
|
||||
else // Newest
|
||||
{
|
||||
var topByTimestamp = _topByTimestamp.Trim();
|
||||
if (topByTimestamp.Length == 0) { return ""; }
|
||||
rowSelector["topByTimestamp"] = topByTimestamp;
|
||||
}
|
||||
|
||||
o["rowSelector"] = rowSelector;
|
||||
}
|
||||
|
||||
var timestampColumn = _timestampColumn.Trim();
|
||||
if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; }
|
||||
|
||||
if (_type.Length > 0) { o["type"] = _type; }
|
||||
|
||||
// v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side).
|
||||
o["writable"] = false;
|
||||
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_token != Guid.Empty)
|
||||
{
|
||||
// Fire-and-forget — don't block circuit teardown on a slow database.
|
||||
_ = BrowserService.CloseAsync(_token);
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
("FOCAS", DriverTypeNames.FOCAS),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Sql", DriverTypeNames.Sql),
|
||||
("Calculation", "Calculation"),
|
||||
];
|
||||
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
|
||||
goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
|
||||
serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
|
||||
group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
|
||||
from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
|
||||
GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by
|
||||
TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Model</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
|
||||
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
|
||||
<option value="@SqlTagModel.KeyValue">KeyValue</option>
|
||||
<option value="@SqlTagModel.WideRow">WideRow</option>
|
||||
</select></div>
|
||||
<div class="col-md-5"><label class="form-label">Table or view</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Type override</label>
|
||||
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
|
||||
<option value="">(infer from column)</option>
|
||||
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@if (_m.Model == SqlTagModel.KeyValue)
|
||||
{
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4"><label class="form-label">Key column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.KeyColumn" placeholder="tag_name" @onchange="@(e => Update(() => _m.KeyColumn = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-4"><label class="form-label">Key value</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.KeyValue" placeholder="Line1.Speed" @onchange="@(e => Update(() => _m.KeyValue = e.Value?.ToString()))" />
|
||||
<div class="form-text">Bound as a parameter — never interpolated.</div></div>
|
||||
<div class="col-md-4"><label class="form-label">Value column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.ValueColumn" placeholder="num_value" @onchange="@(e => Update(() => _m.ValueColumn = NullIfBlank(e.Value)))" /></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4"><label class="form-label">Value column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.ColumnName" placeholder="oven_temp" @onchange="@(e => Update(() => _m.ColumnName = NullIfBlank(e.Value)))" />
|
||||
<div class="form-text">The column this tag reads from the selected row.</div></div>
|
||||
<div class="col-md-4"><label class="form-label">Row selector</label>
|
||||
<select class="form-select form-select-sm" value="@_selectorMode" @onchange="@(e => OnSelectorModeChanged(e.Value?.ToString()))">
|
||||
<option value="Where">Where column = value</option>
|
||||
<option value="Newest">Newest by timestamp</option>
|
||||
</select></div>
|
||||
@if (_selectorMode == "Where")
|
||||
{
|
||||
<div class="col-md-2"><label class="form-label">Where column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereColumn" placeholder="station_id" @onchange="@(e => Update(() => _m.RowSelector.WhereColumn = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">Where value</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereValue" placeholder="7" @onchange="@(e => Update(() => _m.RowSelector.WhereValue = e.Value?.ToString()))" /></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4"><label class="form-label">Order-by (timestamp) column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.TopByTimestamp" placeholder="sample_ts" @onchange="@(e => Update(() => _m.RowSelector.TopByTimestamp = NullIfBlank(e.Value)))" /></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
|
||||
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div></div>
|
||||
<div class="col-12 mt-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
{
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="Sql tag builder"
|
||||
CurrentAddress="@_pickerAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<SqlAddressPickerBody CurrentAddress="@_pickerAddress"
|
||||
CurrentAddressChanged="@((s) => _pickerAddress = s)"
|
||||
GetConfigJson="@GetDriverConfigJson" />
|
||||
</DriverTagPicker>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's current TagConfig JSON.</summary>
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
|
||||
/// <summary>Raised with the updated TagConfig JSON when the model changes.</summary>
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity.</summary>
|
||||
[Parameter] public string DriverType { get; set; } = "";
|
||||
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private SqlTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
private bool _showPicker;
|
||||
private string _pickerAddress = "";
|
||||
|
||||
// Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
|
||||
// carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in
|
||||
// the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state
|
||||
// the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously
|
||||
// says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where".
|
||||
private string _selectorMode = "Where";
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server
|
||||
// live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors).
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = SqlTagConfigModel.FromJson(ConfigJson);
|
||||
DeriveSelectorMode();
|
||||
}
|
||||
|
||||
// Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves
|
||||
// the operator's current choice untouched when both selector fields are blank (an in-progress edit).
|
||||
private void DeriveSelectorMode()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
|
||||
{
|
||||
_selectorMode = "Newest";
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
|
||||
{
|
||||
_selectorMode = "Where";
|
||||
}
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by
|
||||
// NAME (never an ordinal cast), matching the model's strict name-string serialisation.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
// "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip
|
||||
// is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string.
|
||||
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
|
||||
{
|
||||
var s = v?.ToString();
|
||||
if (string.IsNullOrEmpty(s)) { return null; }
|
||||
return Enum.TryParse<TEnum>(s, out var r) ? r : null;
|
||||
}
|
||||
|
||||
// Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the
|
||||
// key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value
|
||||
// as present-and-empty, distinct from absent, so those keep e.Value verbatim.)
|
||||
private static string? NullIfBlank(object? v)
|
||||
{
|
||||
var s = v?.ToString();
|
||||
return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
|
||||
// The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of
|
||||
// the mode being left so the composed blob carries exactly one selector (a stale where-pair would win
|
||||
// over topByTimestamp at read time, silently overriding the operator's choice).
|
||||
private async Task OnSelectorModeChanged(string? mode)
|
||||
{
|
||||
_selectorMode = mode == "Newest" ? "Newest" : "Where";
|
||||
await Update(() =>
|
||||
{
|
||||
if (_selectorMode == "Newest")
|
||||
{
|
||||
_m.RowSelector.WhereColumn = null;
|
||||
_m.RowSelector.WhereValue = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_m.RowSelector.TopByTimestamp = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the
|
||||
// other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working
|
||||
// model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that
|
||||
// the picker never authors.
|
||||
private async Task OnAddressPicked(string blob)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(blob)) { return; }
|
||||
var picked = SqlTagConfigModel.FromJson(blob);
|
||||
await Update(() =>
|
||||
{
|
||||
_m.Model = picked.Model;
|
||||
_m.Table = picked.Table;
|
||||
_m.KeyColumn = picked.KeyColumn;
|
||||
_m.KeyValue = picked.KeyValue;
|
||||
_m.ValueColumn = picked.ValueColumn;
|
||||
_m.ColumnName = picked.ColumnName;
|
||||
_m.TimestampColumn = picked.TimestampColumn;
|
||||
_m.Type = picked.Type;
|
||||
_m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn;
|
||||
_m.RowSelector.WhereValue = picked.RowSelector.WhereValue;
|
||||
_m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp;
|
||||
});
|
||||
DeriveSelectorMode();
|
||||
}
|
||||
}
|
||||
@@ -11,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.OtOpcUa.Driver.Sql.Browser;
|
||||
using ZB.MOM.WW.Secrets.Ui;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
@@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
|
||||
// ctor param has a production default. The universal-browser DI line is deliberately NOT added
|
||||
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
|
||||
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
|
||||
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
|
||||
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
|
||||
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column
|
||||
/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the
|
||||
/// authoritative runtime reader <see cref="SqlEquipmentTagParser.TryParse"/>: the <c>model</c> and
|
||||
/// <c>type</c> enums are written as their NAME strings (never numbers), and <see cref="Validate"/>
|
||||
/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys
|
||||
/// (top-level and inside <c>rowSelector</c>) across a load→save so a future field survives an edit.
|
||||
/// </summary>
|
||||
public sealed class SqlTagConfigModel
|
||||
{
|
||||
/// <summary>Tag→value mapping model. v1 accepts <see cref="SqlTagModel.KeyValue"/> and
|
||||
/// <see cref="SqlTagModel.WideRow"/>; <see cref="SqlTagModel.Query"/> is deferred and rejected.</summary>
|
||||
public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue;
|
||||
|
||||
/// <summary>The table or view to read (e.g. <c>dbo.TagValues</c>). Required for every model.</summary>
|
||||
public string? Table { get; set; }
|
||||
|
||||
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column matched against <see cref="KeyValue"/>.</summary>
|
||||
public string? KeyColumn { get; set; }
|
||||
|
||||
/// <summary><see cref="SqlTagModel.KeyValue"/>: this tag's key value (bound as a parameter at read time).</summary>
|
||||
public string? KeyValue { get; set; }
|
||||
|
||||
/// <summary><see cref="SqlTagModel.KeyValue"/>: the column the value is read from.</summary>
|
||||
public string? ValueColumn { get; set; }
|
||||
|
||||
/// <summary>Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models.</summary>
|
||||
public string? TimestampColumn { get; set; }
|
||||
|
||||
/// <summary><see cref="SqlTagModel.WideRow"/>: the column this tag reads from the selected row.</summary>
|
||||
public string? ColumnName { get; set; }
|
||||
|
||||
/// <summary><see cref="SqlTagModel.WideRow"/>: which row to read — a where-pair or newest-by-timestamp.</summary>
|
||||
public SqlRowSelectorModel RowSelector { get; set; } = new();
|
||||
|
||||
/// <summary>Optional explicit OPC UA data type override (the blob's <c>type</c> field). <c>null</c> ⇒ the
|
||||
/// driver infers the type from the result-set column metadata.</summary>
|
||||
public DriverDataType? Type { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's
|
||||
// strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate).
|
||||
private string? _invalidModelRaw;
|
||||
private string? _invalidTypeRaw;
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
|
||||
/// original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||
/// <returns>The populated <see cref="SqlTagConfigModel"/>.</returns>
|
||||
public static SqlTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var (model, invalidModel) = ReadEnumStrict<SqlTagModel>(o, "model");
|
||||
var (type, invalidType) = ReadEnumStrict<DriverDataType>(o, "type");
|
||||
|
||||
var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null;
|
||||
|
||||
return new SqlTagConfigModel
|
||||
{
|
||||
Model = model ?? SqlTagModel.KeyValue,
|
||||
Table = TagConfigJson.GetString(o, "table"),
|
||||
KeyColumn = TagConfigJson.GetString(o, "keyColumn"),
|
||||
KeyValue = TagConfigJson.GetString(o, "keyValue"),
|
||||
ValueColumn = TagConfigJson.GetString(o, "valueColumn"),
|
||||
TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"),
|
||||
ColumnName = TagConfigJson.GetString(o, "columnName"),
|
||||
RowSelector = SqlRowSelectorModel.FromJson(selectorObj),
|
||||
Type = type,
|
||||
_bag = o,
|
||||
_invalidModelRaw = invalidModel,
|
||||
_invalidTypeRaw = invalidType,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as
|
||||
/// their name strings) over the preserved key bag. The nested <c>rowSelector</c> object is rebuilt from
|
||||
/// <see cref="RowSelector"/> (preserving its own unknown keys) and omitted entirely when empty.</summary>
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "model", Model);
|
||||
TagConfigJson.Set(_bag, "table", Table);
|
||||
TagConfigJson.Set(_bag, "keyColumn", KeyColumn);
|
||||
TagConfigJson.Set(_bag, "keyValue", KeyValue);
|
||||
TagConfigJson.Set(_bag, "valueColumn", ValueColumn);
|
||||
TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn);
|
||||
TagConfigJson.Set(_bag, "columnName", ColumnName);
|
||||
TagConfigJson.Set(_bag, "type", Type);
|
||||
|
||||
var selector = RowSelector.ToJsonObject();
|
||||
if (selector is null) { _bag.Remove("rowSelector"); }
|
||||
else { _bag["rowSelector"] = selector; }
|
||||
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validates the model against the exact accept/reject boundary of
|
||||
/// <see cref="SqlEquipmentTagParser.TryParse"/>. Returns an error message, or <c>null</c> when valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
{
|
||||
// Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag.
|
||||
if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; }
|
||||
if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; }
|
||||
|
||||
// Query is design §5.4 / P3 — the parser rejects it; the editor must too.
|
||||
if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; }
|
||||
|
||||
switch (Model)
|
||||
{
|
||||
case SqlTagModel.KeyValue:
|
||||
if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; }
|
||||
if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; }
|
||||
if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; }
|
||||
return null;
|
||||
|
||||
case SqlTagModel.WideRow:
|
||||
if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; }
|
||||
// A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but
|
||||
// must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`.
|
||||
var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null;
|
||||
if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp))
|
||||
{
|
||||
return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp.";
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return "model is not a supported Sql tag model.";
|
||||
}
|
||||
}
|
||||
|
||||
// Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but-
|
||||
// non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a
|
||||
// present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it.
|
||||
private static (TEnum? value, string? invalidRaw) ReadEnumStrict<TEnum>(JsonObject o, string name)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var s))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for a <see cref="SqlTagModel.WideRow"/> tag's nested <c>rowSelector</c> object.
|
||||
/// Holds a where-pair (<see cref="WhereColumn"/>/<see cref="WhereValue"/>) or a newest-by-timestamp
|
||||
/// selector (<see cref="TopByTimestamp"/>). Preserves unknown keys inside the selector across load→save.
|
||||
/// </summary>
|
||||
public sealed class SqlRowSelectorModel
|
||||
{
|
||||
/// <summary>The column that picks the row (matched against <see cref="WhereValue"/>).</summary>
|
||||
public string? WhereColumn { get; set; }
|
||||
|
||||
/// <summary>The value <see cref="WhereColumn"/> is matched against (bound as a parameter at read time).
|
||||
/// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring
|
||||
/// the parser's scalar read.</summary>
|
||||
public string? WhereValue { get; set; }
|
||||
|
||||
/// <summary>Newest-row selector: the timestamp column to order by (used when no where-pair is authored).</summary>
|
||||
public string? TopByTimestamp { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a selector model from the nested <c>rowSelector</c> JSON object (or a fresh empty model
|
||||
/// when the object is absent), retaining any unknown keys.</summary>
|
||||
/// <param name="o">The nested <c>rowSelector</c> object, or <c>null</c> when absent.</param>
|
||||
/// <returns>The populated <see cref="SqlRowSelectorModel"/>.</returns>
|
||||
public static SqlRowSelectorModel FromJson(JsonObject? o)
|
||||
{
|
||||
// Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson).
|
||||
var bag = o?.DeepClone().AsObject() ?? new JsonObject();
|
||||
return new SqlRowSelectorModel
|
||||
{
|
||||
WhereColumn = TagConfigJson.GetString(bag, "whereColumn"),
|
||||
WhereValue = ReadScalarText(bag, "whereValue"),
|
||||
TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"),
|
||||
_bag = bag,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the nested <c>rowSelector</c> JSON object from the exposed fields over the preserved
|
||||
/// key bag, or returns <c>null</c> when the selector carries nothing (so the parent omits the key).</summary>
|
||||
/// <returns>The <c>rowSelector</c> <see cref="JsonObject"/>, or <c>null</c> when empty.</returns>
|
||||
public JsonObject? ToJsonObject()
|
||||
{
|
||||
var o = _bag.DeepClone().AsObject();
|
||||
TagConfigJson.Set(o, "whereColumn", WhereColumn);
|
||||
TagConfigJson.Set(o, "whereValue", WhereValue);
|
||||
TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp);
|
||||
return o.Count == 0 ? null : o;
|
||||
}
|
||||
|
||||
// Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw
|
||||
// token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar).
|
||||
private static string? ReadScalarText(JsonObject o, string name)
|
||||
{
|
||||
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; }
|
||||
return v.TryGetValue<string>(out var s) ? s : v.ToJsonString();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
@@ -21,6 +22,10 @@ public static class TagConfigEditorMap
|
||||
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
|
||||
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
||||
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
@@ -23,6 +24,8 @@ public static class TagConfigValidator
|
||||
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
|
||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
||||
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
|
||||
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
|
||||
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
|
||||
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
@@ -141,15 +142,39 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
|
||||
/// </remarks>
|
||||
/// <param name="members">The current cluster members, in any order.</param>
|
||||
/// <returns>The oldest Up driver member's address, or <c>null</c> when there is none.</returns>
|
||||
public static Address? SelectDriverPrimary(IEnumerable<Member> members)
|
||||
public static Address? SelectDriverPrimary(IEnumerable<Member> members) =>
|
||||
SelectOldestUpMemberOfRole(members, DriverRole);
|
||||
|
||||
/// <summary>
|
||||
/// Selects the oldest Up member carrying <paramref name="role"/> — the node that
|
||||
/// <c>ClusterSingletonManager</c> would place a role-scoped singleton on.
|
||||
/// </summary>
|
||||
/// <param name="members">The current cluster members, in any order.</param>
|
||||
/// <param name="role">The cluster role to scope the selection to.</param>
|
||||
/// <returns>The oldest Up member's address for that role, or <c>null</c> when there is none.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The age-ordering rationale in <see cref="SelectDriverPrimary"/> applies verbatim to any
|
||||
/// role: oldest, never <c>RoleLeader</c>. The rule itself lives in the shared
|
||||
/// <see cref="ClusterActiveNode"/> (<c>ZB.MOM.WW.Health.Akka</c> 0.3.0) so it has exactly
|
||||
/// one implementation family-wide — this method is the control plane's entry point into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Delegating rather than re-implementing is what keeps the redundancy snapshot and the
|
||||
/// <c>/health/active</c> tier structurally in agreement. They answer for different
|
||||
/// consumers — the OPC UA <c>ServiceLevel</c> 250/240 split reads this election, while an
|
||||
/// orchestrator routes admin traffic by the health tier — so a divergence would advertise
|
||||
/// one node as authoritative while gating the data plane on another. Both apps in the
|
||||
/// family previously kept private copies of this rule, and both got it wrong in a
|
||||
/// different way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static Address? SelectOldestUpMemberOfRole(IEnumerable<Member> members, string role)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(role);
|
||||
|
||||
return members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
.FirstOrDefault()
|
||||
?.Address;
|
||||
return ClusterActiveNode.OldestUpMember(members, role)?.Address;
|
||||
}
|
||||
|
||||
private IReadOnlyList<NodeRedundancyState> BuildSnapshot()
|
||||
|
||||
@@ -189,9 +189,9 @@ public static class ServiceCollectionExtensions
|
||||
/// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub.
|
||||
/// </remarks>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton(
|
||||
this AkkaConfigurationBuilder builder, IClusterRoleInfo roleInfo)
|
||||
this AkkaConfigurationBuilder builder, AkkaClusterOptions clusterOptions)
|
||||
{
|
||||
var singletonOptions = BuildClusterRedundancySingletonOptions(roleInfo);
|
||||
var singletonOptions = BuildClusterRedundancySingletonOptions(clusterOptions);
|
||||
|
||||
builder.WithSingleton<RedundancyStateActorKey>(
|
||||
RedundancyStateSingletonName,
|
||||
@@ -216,13 +216,23 @@ public static class ServiceCollectionExtensions
|
||||
/// (b) on a genuinely split 2-node mesh the <c>driver</c> role is already pair-local — the mesh IS
|
||||
/// the pair. So the fallback is correct in both worlds and costs no availability.
|
||||
/// </remarks>
|
||||
/// <param name="roleInfo">The local node's cluster-role view.</param>
|
||||
/// <param name="clusterOptions">The node's own cluster configuration (roles), read without the
|
||||
/// ActorSystem so this is safe to call inside the Akka configurator lambda.</param>
|
||||
/// <returns>Singleton options scoped to the node's cluster role, or the <c>driver</c> role.</returns>
|
||||
public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(IClusterRoleInfo roleInfo)
|
||||
public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(AkkaClusterOptions clusterOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roleInfo);
|
||||
ArgumentNullException.ThrowIfNull(clusterOptions);
|
||||
|
||||
return new ClusterSingletonOptions { Role = roleInfo.ClusterRole ?? RoleParser.Driver };
|
||||
// Derive the cluster-{ClusterId} scope from the node's OWN configured roles — NOT from
|
||||
// IClusterRoleInfo. IClusterRoleInfo's implementation depends on the ActorSystem (it is a live
|
||||
// Cluster.State view), and this runs inside the AddAkka configurator lambda WHILE the
|
||||
// ActorSystem is being built; resolving IClusterRoleInfo there recurses back into ActorSystem
|
||||
// construction and stack-overflows the host at boot (the Phase 6 live gate caught exactly this
|
||||
// on the fused central node). AkkaClusterOptions is pure configuration, available before the
|
||||
// cluster forms — the same source ClusterRoleInfo.ClusterRole itself derives from — so the
|
||||
// scope is identical. First cluster role wins, in configuration order (matching ClusterRoleInfo).
|
||||
var clusterRole = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole);
|
||||
return new ClusterSingletonOptions { Role = clusterRole ?? RoleParser.Driver };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
|
||||
<PackageReference Include="Grpc.Net.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
|
||||
plane and the /health/active tier must give the SAME answer for which node is in charge,
|
||||
or the tier reports one node active while the Primary-gated data plane runs on another;
|
||||
delegating here is what makes that structural rather than a convention. It lives in
|
||||
Health.Akka because that is the family's Akka-cluster utility package (it already hosts
|
||||
AkkaClusterStatusPolicy and an endpoint gate); a dedicated cluster package would be a
|
||||
better home if a third such primitive ever appears. -->
|
||||
<PackageReference Include="ZB.MOM.WW.Health.Akka"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
@@ -17,7 +18,7 @@ public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
|
||||
/// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh
|
||||
/// ready+active; cluster-primary on active only. The configdb probe is admin-only (per-cluster mesh
|
||||
/// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff <paramref name="hasAdmin"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register the health checks on.</param>
|
||||
@@ -53,11 +54,33 @@ public static class HealthEndpoints
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
|
||||
// "Is this node in charge of its mesh?" — Healthy on the one node that owns the active
|
||||
// work for its Cluster, Unhealthy (503) on its partner, so Traefik pins the AdminUI to the
|
||||
// node actually hosting the cluster singletons.
|
||||
//
|
||||
// Role preference admin-then-driver: a fused central node answers for `admin` (the role
|
||||
// the singletons and the AdminUI are pinned to), while a driver-only site node answers for
|
||||
// `driver` — which is exactly RedundancyStateActor.SelectDriverPrimary, the election behind
|
||||
// IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Both now route through the
|
||||
// shared ClusterActiveNode, so the tier and the ServiceLevel cannot disagree.
|
||||
//
|
||||
// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's ClusterState
|
||||
// contains only its own application Cluster, so "oldest Up member" is already "oldest Up
|
||||
// member of this Cluster" — exactly one node answers 200 per Cluster, by construction.
|
||||
//
|
||||
// Before Health 0.3.0 this was ActiveNodeHealthCheck(role: "admin"), which answered a
|
||||
// different question and got it wrong twice: it returned Healthy for any node lacking the
|
||||
// admin role, so every driver-only site node called itself active, and it selected by
|
||||
// RoleLeader (lowest address) rather than by age, which is not where Akka places
|
||||
// singletons. See lmxopcua#494.
|
||||
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
||||
"admin-leader",
|
||||
"cluster-primary",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active },
|
||||
args: "admin")
|
||||
args: new ActiveNodeHealthCheckOptions
|
||||
{
|
||||
RolePreference = new[] { RoleParser.Admin, RoleParser.Driver },
|
||||
})
|
||||
// Registered on every node regardless of role (unlike the admin-only configdb probe above):
|
||||
// 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
using Serilog.Events;
|
||||
@@ -380,12 +381,25 @@ builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||
// back to the driver role for legacy single-mesh / not-yet-migrated nodes (already pair-local
|
||||
// on a split mesh, since the mesh IS the pair). Runs on the fused central node too (it is
|
||||
// hasDriver), where it scopes to cluster-MAIN — exactly one redundancy singleton per node,
|
||||
// since the admin branch above no longer registers one. IClusterRoleInfo is registered by
|
||||
// AddOtOpcUaCluster and resolved lazily here.
|
||||
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>());
|
||||
// since the admin branch above no longer registers one. Pass AkkaClusterOptions (pure config),
|
||||
// NOT IClusterRoleInfo: this lambda runs WHILE the ActorSystem is being built, and
|
||||
// IClusterRoleInfo depends on the ActorSystem — resolving it here recurses into ActorSystem
|
||||
// construction and stack-overflows the host at boot. The singleton scope only needs the node's
|
||||
// configured cluster role, which AkkaClusterOptions carries.
|
||||
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value);
|
||||
}
|
||||
});
|
||||
|
||||
// Simultaneous-cold-start split-brain guard (dark switch Cluster:BootstrapGuard:Enabled, default off).
|
||||
// Registered AFTER AddAkka so its StartAsync runs once Akka's hosted service has built (and, when the
|
||||
// guard is off, already auto-joined) the ActorSystem. When on, the node started with no config seeds
|
||||
// (BuildClusterOptions) and this coordinator issues the single reachability-gated JoinSeedNodes. It
|
||||
// no-ops when the guard is off, so registering it unconditionally is safe.
|
||||
builder.Services.AddHostedService(sp => new ClusterBootstrapCoordinator(
|
||||
() => sp.GetRequiredService<ActorSystem>(),
|
||||
sp.GetRequiredService<IOptions<AkkaClusterOptions>>(),
|
||||
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
|
||||
|
||||
// Down-if-alone recovery watchdog (#459). Registered AFTER AddAkka so it starts after Akka's own
|
||||
// hosted service has built the ActorSystem; it resolves the system lazily (never at construction) so
|
||||
// it can't race startup. On an unexpected SBR self-down it stops the host so the service supervisor
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||||
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||||
|
||||
ReassertConditionNodes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
|
||||
/// its condition node, right after a (re)load.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
|
||||
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
|
||||
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
|
||||
/// re-derives Active from the predicate, so a still-active alarm ends the reload
|
||||
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
|
||||
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
|
||||
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
|
||||
/// client until its next real transition, which may never come. Same shape as the VirtualTag
|
||||
/// redeploy-reset the <c>ReassertValue</c> fix closed.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
|
||||
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
|
||||
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
|
||||
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
|
||||
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
|
||||
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
|
||||
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
|
||||
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
|
||||
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
|
||||
/// already queued ahead of these writes: the node exists by the time they are handled.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
|
||||
/// matches what the materialise just built, so writing it would be a pure no-op on state —
|
||||
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
|
||||
/// display name; a projection carries the resolved template). Skipping them keeps a
|
||||
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
|
||||
/// at zero on the overwhelmingly common all-normal deploy.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
|
||||
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
|
||||
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
|
||||
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
|
||||
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
|
||||
/// like every other node write here: the Secondary keeps its address space warm for failover.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReassertConditionNodes()
|
||||
{
|
||||
var reasserted = 0;
|
||||
foreach (var projection in _engine.GetProjections())
|
||||
{
|
||||
if (IsInNoEventPosition(projection.Condition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
AlarmNodeId: projection.AlarmId,
|
||||
State: ToSnapshot(projection),
|
||||
// The instant the state we are re-asserting actually came about — NOT the deploy time.
|
||||
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
|
||||
// active, not when the address space happened to be rebuilt.
|
||||
TimestampUtc: projection.Condition.LastTransitionUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
reasserted++;
|
||||
}
|
||||
|
||||
if (reasserted > 0)
|
||||
{
|
||||
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
|
||||
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
|
||||
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
|
||||
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
|
||||
/// <param name="condition">The engine's current condition state.</param>
|
||||
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
|
||||
private static bool IsInNoEventPosition(AlarmConditionState condition)
|
||||
=> condition.Enabled == AlarmEnabledState.Enabled
|
||||
&& condition.Active == AlarmActiveState.Inactive
|
||||
&& condition.Acked == AlarmAckedState.Acknowledged
|
||||
&& condition.Confirmed == AlarmConfirmedState.Confirmed
|
||||
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
|
||||
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
|
||||
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
|
||||
Active: e.Condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message,
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
|
||||
=> ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
|
||||
|
||||
/// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
|
||||
/// to restore a condition node after a rebuild, never an emission. Shares
|
||||
/// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
|
||||
/// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
|
||||
/// <param name="p">The engine projection to map.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
|
||||
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
|
||||
|
||||
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
|
||||
/// paths.</summary>
|
||||
/// <param name="condition">The Part 9 condition state.</param>
|
||||
/// <param name="severity">The engine's coarse severity bucket.</param>
|
||||
/// <param name="message">The resolved condition message.</param>
|
||||
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(
|
||||
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
|
||||
Active: condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(severity),
|
||||
Message: 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));
|
||||
Quality: QualityFromStatus(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>
|
||||
|
||||
@@ -174,4 +174,44 @@ public sealed class AkkaClusterOptionsValidatorTests
|
||||
|
||||
result.Failed.ShouldBeFalse(result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>The bootstrap-guard timing knobs default to positive values and pass when enabled.</summary>
|
||||
[Fact]
|
||||
public void Bootstrap_guard_defaults_pass()
|
||||
{
|
||||
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
|
||||
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true };
|
||||
|
||||
var result = new AkkaClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeFalse(result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A zero <c>PartnerProbeSeconds</c> silently degrades the guard to "never wait, form alone
|
||||
/// immediately" — re-opening the split. It must fail the host at boot, not run degraded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bootstrap_guard_with_non_positive_probe_seconds_fails()
|
||||
{
|
||||
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
|
||||
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = true, PartnerProbeSeconds = 0 };
|
||||
|
||||
var result = new AkkaClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("PartnerProbeSeconds");
|
||||
}
|
||||
|
||||
/// <summary>The timing knobs are NOT validated when the guard is off — a disabled guard is inert.</summary>
|
||||
[Fact]
|
||||
public void Bootstrap_guard_disabled_does_not_validate_timings()
|
||||
{
|
||||
var options = CentralNode("site-a-1", Seed("site-a-1"), Seed("site-a-2"));
|
||||
options.BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = false, PartnerProbeSeconds = 0 };
|
||||
|
||||
var result = new AkkaClusterOptionsValidator().Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeFalse(result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard
|
||||
/// (<see cref="ClusterBootstrapCoordinator"/> + <see cref="ClusterBootstrapGuard"/>), started through
|
||||
/// the SAME wiring as production (<see cref="ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap"/>
|
||||
/// with <c>Cluster:BootstrapGuard:Enabled</c> + the coordinator hosted service). This subsystem has a
|
||||
/// history of bugs invisible to pure unit tests, so the load-bearing correctness properties are
|
||||
/// asserted against running nodes, mirroring <see cref="SelfFirstSeedBootstrapTests"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ephemeral loopback ports are 5-digit, so numeric order equals the guard's ordinal "host:port"
|
||||
/// string order — <c>Min(port)</c> is the founder, <c>Max(port)</c> is the higher (probing) node.
|
||||
/// </remarks>
|
||||
public sealed class ClusterBootstrapCoordinatorTests
|
||||
{
|
||||
private static int FreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a node with the bootstrap guard ENABLED, wired exactly as Program.cs does: empty Akka
|
||||
/// seed list (via <c>BuildClusterOptions</c>) plus the coordinator hosted service that issues the
|
||||
/// reachability-gated <c>JoinSeedNodes</c>. Seeds are listed self-first (as every shipped config
|
||||
/// is); the guard, not the order, decides founder vs. joiner.
|
||||
/// </summary>
|
||||
private static async Task<IHost> StartGuardedNodeAsync(
|
||||
string systemName, int selfPort, int peerPort, int probeSeconds = 4)
|
||||
{
|
||||
var self = $"akka.tcp://{systemName}@127.0.0.1:{selfPort}";
|
||||
var peer = $"akka.tcp://{systemName}@127.0.0.1:{peerPort}";
|
||||
var options = new AkkaClusterOptions
|
||||
{
|
||||
SystemName = systemName,
|
||||
Hostname = "127.0.0.1",
|
||||
PublicHostname = "127.0.0.1",
|
||||
Port = selfPort,
|
||||
Roles = new[] { "driver" },
|
||||
SeedNodes = new[] { self, peer },
|
||||
BootstrapGuard = new ClusterBootstrapGuardOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PartnerProbeSeconds = probeSeconds,
|
||||
PartnerProbeIntervalMs = 250,
|
||||
ProbeConnectTimeoutMs = 500,
|
||||
},
|
||||
};
|
||||
|
||||
var builder = Host.CreateDefaultBuilder();
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
|
||||
services.AddAkka(systemName, (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp));
|
||||
// Mirror Program.cs — the coordinator is what drives the guarded join.
|
||||
services.AddHostedService(sp => new ClusterBootstrapCoordinator(
|
||||
() => sp.GetRequiredService<ActorSystem>(),
|
||||
sp.GetRequiredService<IOptions<AkkaClusterOptions>>(),
|
||||
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
await host.StartAsync();
|
||||
return host;
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForUpMembersAsync(IHost host, int expected, TimeSpan timeout)
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService<ActorSystem>());
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected) return true;
|
||||
await Task.Delay(200);
|
||||
}
|
||||
return cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected;
|
||||
}
|
||||
|
||||
private static async Task StopAsync(IHost host)
|
||||
{
|
||||
try { await host.StopAsync(); }
|
||||
catch (Exception) { /* teardown only */ }
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The FOUNDER (lower address) with the guard on forms a cluster alone when its partner is dead —
|
||||
/// exactly as un-guarded self-first does. The guard must not regress the preferred founder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Founder_forms_alone_when_partner_is_dead()
|
||||
{
|
||||
var low = FreePort();
|
||||
var high = FreePort();
|
||||
var founderPort = Math.Min(low, high);
|
||||
var deadPeerPort = Math.Max(low, high); // nothing listening
|
||||
|
||||
var host = await StartGuardedNodeAsync("otopcua-guard-1", founderPort, deadPeerPort);
|
||||
try
|
||||
{
|
||||
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
|
||||
.ShouldBeTrue("the founder (lower address) must self-form immediately when its partner is down");
|
||||
}
|
||||
finally { await StopAsync(host); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE LOAD-BEARING CASE. The HIGHER node with the guard on must still cold-start ALONE when its
|
||||
/// partner is genuinely dead: it probes the (dead) founder, times out, and falls back to self-first.
|
||||
/// If the guard's peer-first logic were wrong, this node would hang in JoinSeedNodeProcess forever —
|
||||
/// the very regression a naive "always let the lower node found" rule would introduce.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Higher_node_forms_alone_when_partner_is_dead_after_probing()
|
||||
{
|
||||
var low = FreePort();
|
||||
var high = FreePort();
|
||||
var higherPort = Math.Max(low, high);
|
||||
var deadFounderPort = Math.Min(low, high); // the founder is dead
|
||||
|
||||
var host = await StartGuardedNodeAsync("otopcua-guard-2", higherPort, deadFounderPort, probeSeconds: 3);
|
||||
try
|
||||
{
|
||||
// Must come Up AFTER the probe window (~3 s) expires and it falls back to self-first.
|
||||
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
|
||||
.ShouldBeTrue("the higher node must fall back to self-first and form alone once the dead partner never answers the probe");
|
||||
}
|
||||
finally { await StopAsync(host); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FALSIFIABILITY CONTROL for the test above: the higher node does NOT form alone during the probe
|
||||
/// window — it is genuinely waiting/probing, not self-forming immediately (which would mean the
|
||||
/// guard is inert). If this ever starts seeing a member before the window, the probe is not gating.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Higher_node_does_not_form_before_the_probe_window_expires()
|
||||
{
|
||||
var low = FreePort();
|
||||
var high = FreePort();
|
||||
var higherPort = Math.Max(low, high);
|
||||
var deadFounderPort = Math.Min(low, high);
|
||||
|
||||
var host = await StartGuardedNodeAsync("otopcua-guard-3", higherPort, deadFounderPort, probeSeconds: 10);
|
||||
try
|
||||
{
|
||||
// Well within the 10 s probe window: still no cluster, because it is probing the dead founder.
|
||||
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(3)))
|
||||
.ShouldBeFalse("the higher node must probe for the founder, not self-form immediately");
|
||||
}
|
||||
finally { await StopAsync(host); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The higher node JOINS a live founder rather than forming a second cluster: start the founder,
|
||||
/// then the higher node — its probe finds the founder reachable, it commits peer-first, and the
|
||||
/// pair converges to ONE cluster of two.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Higher_node_joins_a_live_founder()
|
||||
{
|
||||
const string systemName = "otopcua-guard-4";
|
||||
var low = FreePort();
|
||||
var high = FreePort();
|
||||
var founderPort = Math.Min(low, high);
|
||||
var higherPort = Math.Max(low, high);
|
||||
|
||||
var founder = await StartGuardedNodeAsync(systemName, founderPort, higherPort);
|
||||
IHost? higher = null;
|
||||
try
|
||||
{
|
||||
(await WaitForUpMembersAsync(founder, 1, TimeSpan.FromSeconds(30)))
|
||||
.ShouldBeTrue("the founder must be Up before the higher node probes it");
|
||||
|
||||
higher = await StartGuardedNodeAsync(systemName, higherPort, founderPort);
|
||||
|
||||
(await WaitForUpMembersAsync(higher, 2, TimeSpan.FromSeconds(60)))
|
||||
.ShouldBeTrue("the higher node must join the founder, forming one cluster of two");
|
||||
(await WaitForUpMembersAsync(founder, 2, TimeSpan.FromSeconds(60)))
|
||||
.ShouldBeTrue("the founder must see the higher node as a member of ITS cluster");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (higher is not null) await StopAsync(higher);
|
||||
await StopAsync(founder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClusterBootstrapGuard"/> — the pure decision core of the
|
||||
/// simultaneous-cold-start split-brain guard. The runtime probe + JoinSeedNodes wiring lives in
|
||||
/// <c>ClusterBootstrapCoordinator</c> and is covered by the live gate.
|
||||
/// </summary>
|
||||
public sealed class ClusterBootstrapGuardTests
|
||||
{
|
||||
private const string A = "akka.tcp://otopcua@site-a-1:4053";
|
||||
private const string B = "akka.tcp://otopcua@site-a-2:4053";
|
||||
|
||||
[Fact]
|
||||
public void Lower_address_node_is_the_founder_and_needs_no_probe()
|
||||
{
|
||||
// site-a-1 < site-a-2, so on site-a-1 the guard makes it the founder.
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053);
|
||||
|
||||
role.Applies.ShouldBeTrue();
|
||||
role.IsFounder.ShouldBeTrue();
|
||||
role.SelfFirstOrder.ShouldBe(new[] { A, B });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Higher_address_node_is_not_the_founder_and_targets_the_partner_for_probing()
|
||||
{
|
||||
// On site-a-2, the partner is site-a-1 (the lower/founder).
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
|
||||
|
||||
role.Applies.ShouldBeTrue();
|
||||
role.IsFounder.ShouldBeFalse();
|
||||
role.PartnerHost.ShouldBe("site-a-1");
|
||||
role.PartnerPort.ShouldBe(4053);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tie_break_is_symmetric_regardless_of_seed_order()
|
||||
{
|
||||
// Both nodes must agree on who founds no matter how each lists its seeds.
|
||||
var onLower = ClusterBootstrapGuard.Analyze(new[] { A, B }, "site-a-1", 4053);
|
||||
var onHigher = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
|
||||
|
||||
onLower.IsFounder.ShouldBeTrue();
|
||||
onHigher.IsFounder.ShouldBeFalse();
|
||||
// Exactly one founder.
|
||||
(onLower.IsFounder ^ onHigher.IsFounder).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Higher_node_joins_the_founder_when_partner_is_reachable()
|
||||
{
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
|
||||
|
||||
// Reachable ⇒ peer-first ⇒ JoinSeedNodeProcess ⇒ joins the founder, never self-forms.
|
||||
ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: true)
|
||||
.ShouldBe(new[] { A, B }); // partner (site-a-1) first
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Higher_node_forms_alone_when_partner_is_unreachable()
|
||||
{
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { B, A }, "site-a-2", 4053);
|
||||
|
||||
// Unreachable after the window ⇒ partner is dead ⇒ self-first ⇒ cold-start-alone preserved.
|
||||
ClusterBootstrapGuard.HigherNodeOrder(role, partnerReachable: false)
|
||||
.ShouldBe(new[] { B, A }); // self (site-a-2) first
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_does_not_apply_to_a_single_seed_node()
|
||||
{
|
||||
// A driver-only site node seeded solely by central-1 is not a pair seed — guard is inert.
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { "akka.tcp://otopcua@central-1:4053" }, "site-x", 4053);
|
||||
|
||||
role.Applies.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_does_not_apply_when_self_is_absent_from_the_two_seeds()
|
||||
{
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { A, B }, "central-1", 4053);
|
||||
|
||||
role.Applies.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_does_not_apply_to_three_or_more_seeds()
|
||||
{
|
||||
var role = ClusterBootstrapGuard.Analyze(
|
||||
new[] { A, B, "akka.tcp://otopcua@site-a-3:4053" }, "site-a-1", 4053);
|
||||
|
||||
role.Applies.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_does_not_apply_when_a_seed_is_malformed()
|
||||
{
|
||||
var role = ClusterBootstrapGuard.Analyze(new[] { A, "not-a-seed" }, "site-a-1", 4053);
|
||||
|
||||
role.Applies.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("akka.tcp://otopcua@site-a-1:4053", "site-a-1", 4053)]
|
||||
[InlineData("akka.tcp://otopcua@10.0.0.5:4053/", "10.0.0.5", 4053)]
|
||||
[InlineData(" akka.tcp://otopcua@host.lan:1234 ", "host.lan", 1234)]
|
||||
public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort)
|
||||
{
|
||||
ClusterBootstrapGuard.TryParse(seed, out var host, out var port).ShouldBeTrue();
|
||||
host.ShouldBe(expectedHost);
|
||||
port.ShouldBe(expectedPort);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("akka.tcp://otopcua@host")] // no port
|
||||
public void TryParse_rejects_malformed_seeds(string seed)
|
||||
{
|
||||
ClusterBootstrapGuard.TryParse(seed, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Port_participates_in_the_tie_break_when_hosts_are_equal()
|
||||
{
|
||||
// Shared-host loopback pair (test rig): same host, different ports — port breaks the tie.
|
||||
const string low = "akka.tcp://otopcua@127.0.0.1:4053";
|
||||
const string high = "akka.tcp://otopcua@127.0.0.1:4054";
|
||||
|
||||
ClusterBootstrapGuard.Analyze(new[] { low, high }, "127.0.0.1", 4053).IsFounder.ShouldBeTrue();
|
||||
ClusterBootstrapGuard.Analyze(new[] { high, low }, "127.0.0.1", 4054).IsFounder.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
|
||||
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
|
||||
/// <em>write</em> side, which is where a credential would actually land in the config database.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorSqlSecretTests
|
||||
{
|
||||
private const string Code = "SqlConnectionStringPersisted";
|
||||
|
||||
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
|
||||
private const string LeakedConfig =
|
||||
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
|
||||
|
||||
private static DriverInstance SqlDriver(string config) => new()
|
||||
{
|
||||
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
|
||||
DriverType = "Sql", DriverConfig = config,
|
||||
};
|
||||
|
||||
private static Device Device(string driverInstanceId, string config) => new()
|
||||
{
|
||||
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
|
||||
};
|
||||
|
||||
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances = [driver],
|
||||
Devices = device is null ? [] : [device],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
|
||||
{
|
||||
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
|
||||
|
||||
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
|
||||
/// problem without repeating the credential it is refusing to store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Error_message_does_not_echo_the_credential()
|
||||
{
|
||||
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
|
||||
|
||||
error.Message.ShouldNotContain("hunter2");
|
||||
error.Message.ShouldNotContain("Server=sql,1433");
|
||||
error.Message.ShouldContain("connectionStringRef");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
|
||||
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ConnectionString")]
|
||||
[InlineData("CONNECTIONSTRING")]
|
||||
[InlineData("connectionstring")]
|
||||
public void Key_match_is_case_insensitive(string key)
|
||||
{
|
||||
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
|
||||
|
||||
DraftValidator.Validate(Draft(SqlDriver(config)))
|
||||
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
|
||||
/// there is the identical leak and must fail the same way.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
|
||||
{
|
||||
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_properly_authored_connectionStringRef_passes()
|
||||
{
|
||||
var draft = Draft(
|
||||
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
|
||||
Device("di-sql", """{"pollIntervalMs":1000}"""));
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
|
||||
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
|
||||
/// be a regression, not defence in depth.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
|
||||
{
|
||||
var modbus = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||
DriverType = "Modbus", DriverConfig = LeakedConfig,
|
||||
};
|
||||
|
||||
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
|
||||
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_device_under_a_non_Sql_driver_is_not_flagged()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances =
|
||||
[
|
||||
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
|
||||
new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||
DriverType = "Modbus", DriverConfig = "{}",
|
||||
},
|
||||
],
|
||||
Devices = [Device("di-mb", LeakedConfig)],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
|
||||
/// rule's job, and a parse failure here would take down every other check in the same pass.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("\"connectionString\"")]
|
||||
[InlineData("{\"provider\":\"SqlServer\"")]
|
||||
public void Malformed_config_neither_throws_nor_flags(string config)
|
||||
{
|
||||
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
|
||||
.ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
|
||||
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
|
||||
/// positive on, say, a tag blob that happens to describe a connection string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_nested_connectionString_is_not_flagged()
|
||||
{
|
||||
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
|
||||
|
||||
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards every driver's hard-coded OPC UA status-code constant against
|
||||
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
|
||||
/// <em>name</em> disagrees with its <em>value</em> fails here.
|
||||
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
|
||||
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
|
||||
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
|
||||
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
|
||||
/// Gitea #497 found in six places across four drivers.</para>
|
||||
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
|
||||
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
|
||||
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
|
||||
/// covered automatically, with no edit here.</para>
|
||||
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
|
||||
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
|
||||
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
|
||||
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
|
||||
/// </summary>
|
||||
public sealed class StatusCodeParityTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
|
||||
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
|
||||
/// that does not start with one of them is not a status constant.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
|
||||
|
||||
/// <summary>
|
||||
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
|
||||
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
|
||||
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
|
||||
/// </summary>
|
||||
private static readonly string[] NamePrefixesToStrip = ["Status"];
|
||||
|
||||
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
|
||||
typeof(Opc.Ua.StatusCodes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
|
||||
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
|
||||
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
|
||||
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
|
||||
/// modifiers say nothing about whether a value reaches an OPC UA client.
|
||||
/// </remarks>
|
||||
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
|
||||
{
|
||||
var data = new TheoryData<string, string, string, string, uint>();
|
||||
|
||||
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
|
||||
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
|
||||
{
|
||||
Assembly asm;
|
||||
try { asm = Assembly.LoadFrom(dll); }
|
||||
catch { continue; }
|
||||
|
||||
Type?[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (type is null) continue;
|
||||
|
||||
var fields = type.GetFields(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
|
||||
|
||||
var sdkName = StripKnownPrefix(field.Name);
|
||||
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
|
||||
|
||||
data.Add(
|
||||
asm.GetName().Name!,
|
||||
type.FullName ?? type.Name,
|
||||
field.Name,
|
||||
sdkName,
|
||||
(uint)field.GetRawConstantValue()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
|
||||
private static string StripKnownPrefix(string fieldName)
|
||||
{
|
||||
foreach (var prefix in NamePrefixesToStrip)
|
||||
{
|
||||
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return fieldName[prefix.Length..];
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver's status constant must carry the value the SDK gives the code it is named after.
|
||||
/// </summary>
|
||||
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
|
||||
/// <param name="type">The declaring type's full name (failure-message context).</param>
|
||||
/// <param name="field">The constant's name as declared.</param>
|
||||
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
|
||||
/// <param name="declared">The value the driver declares.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(DeclaredStatusConstants))]
|
||||
public void Driver_status_constant_matches_the_pinned_SDK(
|
||||
string assembly, string type, string field, string sdkName, uint declared)
|
||||
{
|
||||
SdkStatusCodes.ShouldContainKey(sdkName,
|
||||
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
|
||||
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
|
||||
"not start with Good/Uncertain/Bad.");
|
||||
|
||||
var expected = SdkStatusCodes[sdkName];
|
||||
declared.ShouldBe(expected,
|
||||
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
|
||||
$"is 0x{expected:X8}" +
|
||||
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
|
||||
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
|
||||
: ". The declared value is not any OPC UA status code."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
|
||||
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
|
||||
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
|
||||
{
|
||||
var found = DeclaredStatusConstants();
|
||||
found.Count.ShouldBeGreaterThan(20,
|
||||
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
|
||||
"or the naming convention changed");
|
||||
|
||||
var assemblies = found
|
||||
.Select(row => row.Data.Item1)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
assemblies.Length.ShouldBeGreaterThan(3,
|
||||
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
|
||||
}
|
||||
}
|
||||
+7
@@ -12,6 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -34,6 +36,11 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
|
||||
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||
puts it in StatusCodeParityTests' scope. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
|
||||
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
|
||||
/// transition (which for a still-active alarm with static dependencies may never come).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
|
||||
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
|
||||
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
|
||||
/// report the state, and reading it does not fire an emission.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmProjectionTests
|
||||
{
|
||||
private static ScriptedAlarmDefinition Alarm(
|
||||
string id = "HighTemp",
|
||||
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
|
||||
string msg = "Temp is {Temp}",
|
||||
AlarmSeverity sev = AlarmSeverity.High) =>
|
||||
new(AlarmId: id,
|
||||
EquipmentPath: "Plant/Line1/Reactor",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.AlarmCondition,
|
||||
Severity: sev,
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
|
||||
private static AlarmConditionState PersistedActive(
|
||||
string alarmId = "HighTemp",
|
||||
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
|
||||
DateTime? lastTransitionUtc = null) =>
|
||||
new(alarmId,
|
||||
AlarmEnabledState.Enabled,
|
||||
AlarmActiveState.Active,
|
||||
acked,
|
||||
AlarmConfirmedState.Confirmed,
|
||||
ShelvingState.Unshelved,
|
||||
lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
|
||||
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
|
||||
Comments: []);
|
||||
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
|
||||
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
|
||||
[Fact]
|
||||
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150); // predicate still true — no transition to report
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
var emissions = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
|
||||
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
|
||||
lock (emissions)
|
||||
{
|
||||
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
|
||||
}
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.AlarmId.ShouldBe("HighTemp");
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
}
|
||||
|
||||
/// <summary>The projection carries the definition's severity and the message template resolved against
|
||||
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_definition_severity_and_a_resolved_message()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
|
||||
}
|
||||
|
||||
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
|
||||
/// or every deploy would append a duplicate historian / alerts row.</summary>
|
||||
[Fact]
|
||||
public async Task Reading_projections_never_fires_an_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var emissions = 0;
|
||||
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
|
||||
|
||||
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
|
||||
|
||||
Volatile.Read(ref emissions).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
|
||||
/// "nothing to restore" from "restore this".</summary>
|
||||
[Fact]
|
||||
public async Task A_normal_alarm_projects_the_no_event_position()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate false
|
||||
using var eng = Build(up, new InMemoryAlarmStateStore());
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
|
||||
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
}
|
||||
|
||||
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
|
||||
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_the_last_observed_worst_input_quality()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
|
||||
|
||||
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
|
||||
|
||||
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
|
||||
}
|
||||
|
||||
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
|
||||
[Fact]
|
||||
public void Projections_are_empty_before_the_first_load()
|
||||
{
|
||||
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
|
||||
eng.GetProjections().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC DA category byte.</param>
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, (byte)192)] // Good
|
||||
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x40000000u, (byte)64)] // Uncertain
|
||||
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x80000000u, (byte)0)] // Bad
|
||||
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
|
||||
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user