Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d51d35bd64 | |||
| 8c6067b62e | |||
| 826312befe | |||
| ebcbd81fca | |||
| 4f537a5652 | |||
| c89912a93b | |||
| c4b4046832 | |||
| eed7cc7d75 | |||
| 5310a7e07a | |||
| e1feb9fbb6 | |||
| 1bf8c614a9 | |||
| bc80b53e0e | |||
| f478b844a1 | |||
| 1e71a25e3d | |||
| 8828e999c4 | |||
| 4f7a07fbd3 | |||
| da2e441bb9 | |||
| c8e3e279b4 | |||
| 468f13cb56 | |||
| f71fe13826 | |||
| 6f0674296b | |||
| 19bc13bf6a | |||
| cd50db136e | |||
| eecf301200 | |||
| 76bdca90e9 | |||
| 8ff9f38bbd | |||
| 495606b9fb | |||
| f794dc50b6 | |||
| 7b23875070 | |||
| 109163621c | |||
| 0ccfbbee5c |
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**"
|
||||||
|
- "docs/ScriptedAlarms.md"
|
||||||
|
- "docs/AlarmTracking.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scripted alarm ack/shelve and Part 9 condition method behaviour
|
||||||
|
|
||||||
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
||||||
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
||||||
|
|
||||||
|
## Scripted Alarm Ack/Shelve
|
||||||
|
|
||||||
|
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` role — conferred by the session's API-key principal's `NodeAcl` grants (or the `anonymous` principal's), carried by `RoleCarryingUserIdentity`; LDAP is AdminUI-only; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
|
||||||
|
|
||||||
|
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
|
||||||
|
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
|
||||||
|
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
|
||||||
|
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
|
||||||
|
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
|
||||||
|
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
|
||||||
|
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
|
||||||
|
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
|
||||||
|
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
|
||||||
|
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
|
||||||
|
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
|
||||||
|
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
|
||||||
|
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
|
||||||
|
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
|
||||||
|
|
||||||
|
Behaviours that were wrong or missing and are now fixed — keep them: the SDK's `OnTimedUnshelve` is a
|
||||||
|
**no-op** (the engine's `AutoUnshelve` owns timed-shelve expiry, and `UnshelveTime` reports the real
|
||||||
|
remaining time from `AlarmConditionSnapshot.UnshelveAtUtc`); a **commented** re-ack is stored as an
|
||||||
|
`AddComment` (durable, replicated) and reports one event, an uncommented re-ack stays silent; a **native
|
||||||
|
ack is refused `BadInvalidState` off-Primary** via `NativeAlarmAckPrimaryGate` (fed by
|
||||||
|
`IDriverPrimaryRoleSource`; `DriverHostActor` publishes its Primary verdict at host start and on member
|
||||||
|
changes, so the boot window before the first redundancy snapshot is closed — the same fix covers the MQTT
|
||||||
|
Sparkplug primary-host claim). A native re-ack is still re-sent to the driver (the retry path, by design).
|
||||||
|
|
||||||
|
**Condition `Severity` is carried verbatim, clamped to 1..1000** — `ToPart9Severity`; the node manager used to re-bucket onto
|
||||||
|
the five `EventSeverity` points (so an MTConnect Fault read 900, scripted Critical 1000 read 900, native Low 200 read 300).
|
||||||
|
ScadaBridge only clamps, so exact values pass through. ⚠️ **Never select a two-state id with `AddSelectClause(type, "X/Id")`** —
|
||||||
|
the string becomes ONE QualifiedName named `X/Id`, which no event carries; the Client.CLI/Client.UI filter did this since
|
||||||
|
`a2883b82` and every alarm read inactive+unacked. Use `new SimpleAttributeOperand(type, [X, Id])` (ScadaBridge already does).
|
||||||
|
|
||||||
|
**Condition children are instance-scoped (#629).** Every child of a materialised condition carries
|
||||||
|
`{ConditionId}/{browse path}` in the condition's namespace (e.g. `…/temp_hi/ShelvingState/UnshelveTime`), assigned by
|
||||||
|
the `OtOpcUaNodeManager.New` override — the SDK default kept each child on the TYPE's ns=0 id, so a direct Read hit
|
||||||
|
the type (or `BadNodeIdUnknown`), translate failed, and children sat outside the ACL (the slash join now puts them
|
||||||
|
under their condition's scope). ⚠️ The shelve methods resolve by the `AlarmConditionType_ShelvingState_*` ids only
|
||||||
|
because `New` adopts those (a fixed set) as their `MethodDeclarationId`; calls by the `ShelvedStateMachineType_*` id
|
||||||
|
are no longer supported (pinned by a test). Condition teardown must forget the whole `PredefinedNodes` subtree
|
||||||
|
(`ForgetPredefinedSubtree`) or a removed condition's children keep answering.
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Cluster/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Telemetry/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Host/**"
|
||||||
|
- "docker-dev/**"
|
||||||
|
- "docs/Redundancy.md"
|
||||||
|
- "docs/Configuration.md"
|
||||||
|
- "docs/Telemetry.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cluster topology: redundancy, role authority, per-cluster meshes, MeshTransport, ConfigSource/ConfigServe, telemetry transport, LocalDb, DB-less driver nodes
|
||||||
|
|
||||||
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
||||||
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
||||||
|
|
||||||
|
## Redundancy
|
||||||
|
|
||||||
|
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and the same mxaccessgw (under distinct `MxAccess.ClientName` values) but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
|
||||||
|
|
||||||
|
**Two corrections landed 2026-07-21 — both were total-outage or wrong-node defects, and both are worth knowing before touching this area.**
|
||||||
|
|
||||||
|
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is CLOSED** — Phase 7 drill D4 (2026-07-24) ran the 1-vs-1 crash-the-oldest survival drill on the three-mesh docker-dev rig and every survivor stayed Up, never self-downing (`c50ebcf7`; `docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
|
||||||
|
- **The Primary is the oldest Up `driver` member, not the role leader.** `RedundancyStateActor` previously used `ClusterState.RoleLeader("driver")` (lowest *address*), while `ClusterSingletonManager` places singletons on the *oldest*. They agree only on a freshly-formed cluster and diverge after any restart, so the Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) could enable on a node not hosting the singletons. `NodeRedundancyState.IsRoleLeaderForDriver` was renamed **`IsDriverPrimary`**.
|
||||||
|
|
||||||
|
**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.** ⚠️ **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` dials each node directly over gRPC — one `NodeCommandService.Execute` call per enabled `ClusterNode` row carrying a `GrpcPort` — rather than fanning through a client/receptionist object. **Superseded by the [ClusterClient→gRPC migration](docs/plans/2026-08-06-clusterclient-to-grpc-design.md):** the Akka `ClusterClient` this bullet originally described (one per application `Cluster`, closing the `TODO(Phase 6)` Phase 2 left behind) is deleted; the per-node gRPC dial is its replacement, not an evolution of it.
|
||||||
|
- **`SplitTopologyTransportValidator` fails host start** on a node carrying a `cluster-{ClusterId}` role unless it also runs the split-safe transports: `MeshTransport:Mode=Grpc`, `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.
|
||||||
|
- **The compiled transport-mode defaults were deliberately NOT flipped** — `MeshTransport:Mode`/`Telemetry:Mode`/`TelemetryDial:Mode` all still default to `Dps`; a blast-radius assessment showed flipping the default breaks every integration test and appsettings profile that leaves the key unset. The validator + explicit per-deployment config is the guardrail, not a default flip.
|
||||||
|
- **Secrets replication is pair-local** — each pair replicates its own Akka DPS secrets within itself; there is no fleet-wide/cross-cluster sync after the split (sites have no SQL, so no SQL-hub, consistent with Phase 4).
|
||||||
|
|
||||||
|
See `docs/Redundancy.md` §"Per-cluster meshes (Phase 6)", `docs/Configuration.md` §"Per-cluster mesh split (Phase 6)", and `docs/plans/2026-07-24-mesh-phase6-partition-and-colocation.md`.
|
||||||
|
|
||||||
|
## Role authority (`EffectiveRoles`)
|
||||||
|
|
||||||
|
**A node's roles are resolved in exactly one place** (archreview R3-04, closing 01/S-12 = 03/C5 and
|
||||||
|
01/C-9): `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`), with one precedence —
|
||||||
|
`Cluster:Roles` when non-empty, else `OTOPCUA_ROLES`, else empty — and two-layer normalization: a
|
||||||
|
plain role (`admin`/`driver`/`dev`) is trimmed and lower-cased, but a cluster role's ClusterId suffix
|
||||||
|
(`cluster-SITE-A`) is kept verbatim, because lower-casing it would rename the Akka role of a running
|
||||||
|
deployment. An unknown role from **either** source fails host start. `Program.cs` resolves one
|
||||||
|
`EffectiveRoles` instance at the top of the composition root and registers it as a singleton; that
|
||||||
|
SAME instance drives both the conditional host wiring (`hasAdmin`/`hasDriver`) and
|
||||||
|
`AkkaClusterOptions.Roles` (what Akka gossips) — there is no second derivation for the two to
|
||||||
|
disagree about, which is why no runtime "do the views agree" assert exists. Before this, `Program.cs`
|
||||||
|
read `OTOPCUA_ROLES` only, so a node configured solely through `Cluster:Roles` gossiped the right
|
||||||
|
Akka roles while `Program.cs`'s own wiring mounted none of them — the inverse of the pre-#532 field
|
||||||
|
incident (`docs/deployments/wonder-app-vd03-makino-z-34184.md`).
|
||||||
|
|
||||||
|
- **A role overlay may not itself set `Cluster:Roles`.** Roles are resolved *before* the per-role
|
||||||
|
appsettings overlay loads (the overlay's filename depends on the answer), so a later layer changing
|
||||||
|
`Cluster:Roles` would leave the bound options disagreeing with the registered instance.
|
||||||
|
`AkkaClusterOptionsValidator` fails host start if this ever happens, naming both views — a no-op
|
||||||
|
today since none of the four shipped overlays carries a `Cluster` section.
|
||||||
|
- **Both sources set to different normalized role sets logs a Warning** at boot (`Cluster:Roles`
|
||||||
|
still wins outright) — the boot banner also names which source won (`source=Configured` /
|
||||||
|
`Environment` / `None`).
|
||||||
|
- **`RoleParser.GossipedRolesHave`** (case-insensitive) is defence in depth at the handful of
|
||||||
|
production sites that read LIVE GOSSIP directly (`Member.Roles`) rather than through
|
||||||
|
`EffectiveRoles` — a peer running an older, pre-R3-04 build can still gossip a miscased plain role
|
||||||
|
during a rolling upgrade. ⚠️ **Accepted residual (#620), not closed by R3-04:**
|
||||||
|
`RedundancyStateActor`'s election delegates to `ZB.MOM.WW.Health.Akka`'s `ClusterActiveNode.OldestUpMember`,
|
||||||
|
which matches via Akka's OWN ordinal `Member.HasRole`. Deliberately **not** patched — neither in the
|
||||||
|
library nor with a local case-insensitive pre-filter, which would split the Primary election from
|
||||||
|
`ClusterSingletonManager` placement (also ordinal). It only bites a mixed fixed/pre-fix rolling-upgrade
|
||||||
|
window; finish the upgrade. Full rationale: `docs/Redundancy.md` ("Accepted residual (#620)").
|
||||||
|
- **`Program.cs` wraps the composition root** (#618): a start-up failure logs Fatal, writes `FATAL:` to
|
||||||
|
stderr (the bootstrap logger is still silent before `AddZbSerilog`), flushes, and `Environment.Exit(1)`s
|
||||||
|
— leftover Akka/Kestrel threads would otherwise keep the process alive. `HostAbortedException`
|
||||||
|
(design-time tooling) passes through untouched.
|
||||||
|
- **`RoleAuthorityGuardTests`** (`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/`) is the enforcement:
|
||||||
|
a source scan failing on any production file outside `EffectiveRoles.cs`/`RoleParser.cs` that reads
|
||||||
|
`OTOPCUA_ROLES`, `Cluster:Roles` as code, or declares a bare `"admin"`/`"driver"`/`"dev"` literal —
|
||||||
|
with a small, reasoned allow-list for the handful of genuinely unrelated domain uses of those same
|
||||||
|
English words (a raw-tree node-kind label, a Sql driver's own JSON marker, an LDAP-derived AdminUI
|
||||||
|
RBAC role).
|
||||||
|
|
||||||
|
See `docs/Configuration.md` §`Roles`, `docs/ServiceHosting.md` §"Role gating", and
|
||||||
|
`archreview/plans/R3-04-one-role-authority-plan.md`.
|
||||||
|
|
||||||
|
## Mesh command transport (`MeshTransport`)
|
||||||
|
|
||||||
|
**The Akka `ClusterClient` transport described in this section's earlier history is DELETED**
|
||||||
|
(branch `feat/grpc-phase3-deletion`, [ClusterClient→gRPC migration](docs/plans/2026-08-06-clusterclient-to-grpc-design.md),
|
||||||
|
Phase 3) — code, the `akka.cluster.client` HOCON block, the receptionist extension,
|
||||||
|
`MeshTransport:CentralContactPoints`, and the `ClusterClient` mode value itself, which now **fails
|
||||||
|
validation loudly** at boot (`MeshTransportOptionsValidator` names the key and the allowed values)
|
||||||
|
rather than silently falling back to `Dps`. What remains is exactly two transports, selected by
|
||||||
|
`MeshTransport:Mode`:
|
||||||
|
|
||||||
|
- **`Dps`** (default) — DistributedPubSub, the path this repo has always used. **Pair-local only**:
|
||||||
|
it works only when central and the node share a gossip ring, which after Phase 6 means within one
|
||||||
|
2-node mesh.
|
||||||
|
- **`Grpc`** — unary gRPC calls on the `mesh_transport.v1` contract. **Required** on any node
|
||||||
|
carrying a `cluster-{ClusterId}` role; `SplitTopologyTransportValidator` refuses to boot such a
|
||||||
|
node on anything else.
|
||||||
|
|
||||||
|
Central→node commands (`deployments`, `driver-control`, `alarm-commands`) ride `NodeCommandService.Execute`,
|
||||||
|
served on each node's driver-role telemetry h2c listener (`Telemetry:GrpcListenPort`, the docker-dev
|
||||||
|
rig's `:4056` — it shares that listener with `TelemetryStreamGrpcService` rather than claiming a port
|
||||||
|
of its own). `CentralCommunicationActor` dials it **directly, one call per node** — the target set is
|
||||||
|
every enabled, non-maintenance `ClusterNode` row carrying a `GrpcPort`, dialled at `Host`+`GrpcPort`
|
||||||
|
and refreshed every `MeshTransport:ContactRefreshSeconds`. There is no client/receptionist object and
|
||||||
|
no rotation: each command goes to the specific node dialled, which is what makes per-node delivery
|
||||||
|
provable — the old `ClusterClient.SendToAll`/`Send` distinction this section used to explain no longer
|
||||||
|
applies to either direction.
|
||||||
|
|
||||||
|
Node→central deploy acks ride `CentralCommandService.ReportApply`, served on central's admin-role
|
||||||
|
config-serve h2c listener (`ConfigServe:GrpcListenPort`, the rig's `:4055` — again sharing rather than
|
||||||
|
claiming a new port). `GrpcApplyAckSender` walks `MeshTransport:CentralGrpcEndpoints` in order and
|
||||||
|
stops at the first success.
|
||||||
|
|
||||||
|
Auth is a **per-cluster PSK bearer**, both directions: `MeshTransport:GrpcPsk` (node-side, one key per
|
||||||
|
application `Cluster`) and `MeshTransport:ClusterPsks` (central-side map, consulted before the Secrets
|
||||||
|
store). `MeshCommandAuthInterceptor` (node) and `CentralCommandAuthInterceptor` (central) fail closed
|
||||||
|
on every unresolvable case — constant-time compare, no key material logged. Production keys are
|
||||||
|
`${secret:OT-MESH-PSK-<clusterId>}`; the docker-dev rig supplies three literal dev keys via env
|
||||||
|
(`dev-mesh-psk-MAIN`/`-SITE-A`/`-SITE-B`).
|
||||||
|
|
||||||
|
Things that carried over unchanged from the `ClusterClient` era, because they were never
|
||||||
|
`ClusterClient`-specific to begin with:
|
||||||
|
|
||||||
|
- **Fire-and-forget, no retry, on both transports.** A failed gRPC dispatch is logged (naming the node
|
||||||
|
and the gRPC status) and dropped in the dispatch second. No queue, no ordering guarantee across
|
||||||
|
nodes — the deploy coordinator's apply deadline and a node's boot-time replay are the only recovery.
|
||||||
|
The old `buffer-size = 0` decision this section used to describe is gone with the HOCON block that
|
||||||
|
set it; the gRPC transport has nothing to buffer in the first place — a dial either lands or doesn't.
|
||||||
|
- **Inbound commands re-emit on the node's `EventStream`, not back onto DPS.** DPS is mesh-wide, so a
|
||||||
|
DPS re-publish would deliver N copies to every subscriber. Node-side subscribers (`DriverHostActor`,
|
||||||
|
`ScriptedAlarmHostActor`) accept a command from either transport identically.
|
||||||
|
- **Do not over-claim what fire-and-forget buys.** The Phase 2 live gate observed a node applying a
|
||||||
|
deployment **44 s after it was sealed `TimedOut`** anyway, via `DriverHostActor.Bootstrap()`
|
||||||
|
replaying the orphan `Applying` row on restart. That backstop is unaffected by the ClusterClient
|
||||||
|
deletion — it was always about the deploy path, not the transport.
|
||||||
|
|
||||||
|
`redundancy-state` stays on DPS in both modes (bidirectional, and built from `Cluster.State`, which
|
||||||
|
makes it genuinely mesh-bound). See `docs/Configuration.md` §`MeshTransport`, `docs/Redundancy.md`
|
||||||
|
§"Command transport", and the migration's [design doc](docs/plans/2026-08-06-clusterclient-to-grpc-design.md) /
|
||||||
|
[plan](docs/plans/2026-08-06-clusterclient-to-grpc.md) / [tracking doc](docs/plans/2026-08-06-clusterclient-to-grpc-tracking.md) /
|
||||||
|
[live gate](docs/plans/2026-08-06-clusterclient-to-grpc-live-gate.md) for the phase-by-phase history —
|
||||||
|
those are historical records, not updated further by this sweep. `docs/plans/2026-07-22-mesh-phase2-clusterclient-transport.md`
|
||||||
|
is the original Phase 2 design doc and is likewise left as a historical record of the transport this
|
||||||
|
section now describes as deleted.
|
||||||
|
|
||||||
|
**Phase 1 landed 2026-07-22 and changed a deploy-path behaviour.** `ConfigPublishCoordinator` sources its expected-ack set from **enabled `ClusterNode` rows**, not from `Akka.Cluster.State.Members` filtered by the `driver` role — central must be able to name a deployment's nodes without sharing a gossip ring with them. Consequences worth knowing before touching deploys:
|
||||||
|
|
||||||
|
- **A configured node that is switched off now FAILS the deployment** at the apply deadline instead of letting it seal green without that node. The maintenance hatch is the **new `ClusterNode.MaintenanceMode` column**, NOT `Enabled = 0`: the live gate showed `Enabled = 0` is rejected outright by `DraftValidator.ValidateClusterTopology` (`ClusterEnabledNodeCountMismatch` — enabled count must equal `ServerCluster.NodeCount`), so on a 2-node pair — every cluster in the target topology — it can never be the hatch. `Enabled` = part of the declared topology; `MaintenanceMode` = do not expect it now. The deadline log names the silent nodes and points at the right flag.
|
||||||
|
- **Every `ClusterNode` row is now *defined* to be a driver node.** The DB has no per-node role column and deliberately did not gain one (it would drift from `Cluster:Roles`). Giving an admin-only node a `ClusterNode` row makes every deploy time out.
|
||||||
|
- **There is no such thing as a cluster-scoped deployment.** `Deployment` has no `ClusterId`, `ConfigComposer` always snapshots the whole DB, and `DeploymentArtifact.ResolveClusterScope` is *node-side* self-scoping of a fleet-wide artifact. The phase-1 plan asked for cluster-scope filtering of the ack set; it was dropped as describing a feature that does not exist.
|
||||||
|
- `ClusterNode` gained `AkkaPort` (NOT NULL, 4053) + `GrpcPort` (nullable) as **central's dial targets** for Phases 2/5. They duplicate the node's own `Cluster:Port` / `Cluster:PublicHostname` with no schema-level enforcement, so `ClusterNodeAddressReconcilerActor` (admin singleton) logs an Error on drift. **Phase 2 must revisit it** — once the meshes split, an admin node cannot see site members and every site row would report `EnabledRowNotInCluster` forever. **Resolved, and not the way this bullet originally expected:** the [ClusterClient→gRPC migration](docs/plans/2026-08-06-clusterclient-to-grpc-design.md) deleted the Akka `ClusterClient` transport that would have dialled `Host`+`AkkaPort`; the Phase 2 command dial now uses `Host`+`GrpcPort`, the same column Phase 5 telemetry already dialled — `AkkaPort` is Akka remoting only again.
|
||||||
|
|
||||||
|
## Config source (`ConfigSource` / `ConfigServe`)
|
||||||
|
|
||||||
|
Per-cluster mesh **Phase 3** (config fetch-and-cache) added a second way a **driver** node obtains its
|
||||||
|
deployed configuration, selected by `ConfigSource:Mode`: `Direct` (read `Deployment.ArtifactBlob` from
|
||||||
|
central SQL — **the default**, byte-for-byte today's behaviour) or `FetchAndCache` (fetch the artifact
|
||||||
|
bytes from central over a dedicated-h2c **gRPC stream**, verify `SHA-256 == RevisionHash`, cache in
|
||||||
|
LocalDb, and read **only** the cache — no central-SQL config read). Central (admin role) serves via
|
||||||
|
`DeploymentArtifactService` behind `ConfigServeAuthInterceptor` on `ConfigServe:GrpcListenPort`, gated by
|
||||||
|
a **shared node bearer key** (`ConfigServe:ApiKey == ConfigSource:ApiKey`, `FixedTimeEquals`,
|
||||||
|
fail-closed). Things worth knowing before touching it:
|
||||||
|
|
||||||
|
- **The serve side is wired in both modes** (mapped whenever `ConfigServe:GrpcListenPort > 0` on an
|
||||||
|
admin node), so flipping a node to `FetchAndCache` is a config change, not a redeploy — the same
|
||||||
|
dark-switch discipline as [MeshTransport](#mesh-command-transport-meshtransport).
|
||||||
|
- **`RevisionHash == SHA-256(artifact bytes)` is load-bearing.** `ConfigComposer` computes the revision
|
||||||
|
hash as the lowercase-hex SHA-256 of the exact blob, so the node verifies a fetch against the
|
||||||
|
`RevisionHash` the dispatch already carries — **no hash rides in the proto, and `DispatchDeployment`
|
||||||
|
stays payload-free.** A future change to how the revision hash is derived silently breaks every fetch.
|
||||||
|
**The blob is gzip-compressed since 2026-08-15, and the hash covers the COMPRESSED bytes** —
|
||||||
|
`ConfigComposer.Pack` gzips (`CompressionLevel.Fastest`) immediately after `SerializeToUtf8Bytes` and
|
||||||
|
hashes the result, so the invariant holds unchanged and nothing between the composer and
|
||||||
|
`DeploymentArtifact.Open` (SQL `ArtifactBlob`, the LocalDb cache chunks, the gRPC fetch stream) ever
|
||||||
|
looks inside. `Open` sniffs the gzip magic `1F 8B` — valid JSON can never start with `0x1F` — so
|
||||||
|
pre-upgrade uncompressed rows keep parsing; inflation runs under a 512 MiB guard and every
|
||||||
|
decompression failure surfaces as `JsonException`, the same family the malformed-JSON paths already
|
||||||
|
handle. Two operational consequences in `docs/Configuration.md` §`ConfigSource`: the first deploy after
|
||||||
|
upgrade **re-hashes every revision** (a one-time full re-dispatch, not a no-op), and **nodes must be
|
||||||
|
upgraded BEFORE central starts emitting compressed blobs.**
|
||||||
|
- **A null fetch is "no answer," never an empty config (#485).** All endpoints down / `NotFound` / SHA
|
||||||
|
mismatch / zero-length ⇒ the fetcher returns null ⇒ the apply FAILS, the revision does not advance,
|
||||||
|
and the node keeps serving last-known-good. It never tears down to an empty address space. At boot a
|
||||||
|
`FetchAndCache` node restores served state from the LocalDb pointer with **no** central-SQL read; an
|
||||||
|
empty/unreadable cache lands Steady-with-no-revision (the first dispatch fetches), **never Stale**.
|
||||||
|
**Malformed non-empty bytes are "no answer" too** — on cache boot, the `FetchAndCache` boot reconcile and
|
||||||
|
the subscription push alike (the lenient swallow leg is closed). A cache boot from malformed bytes does
|
||||||
|
**not** adopt the revision (`Direct` ⇒ Stale; `FetchAndCache` ⇒ no revision, the re-dispatch fetches).
|
||||||
|
- **Both pair nodes fetch independently** — no Primary gating of the fetch (same shape as both reading
|
||||||
|
SQL today; `StoreAsync` is idempotent and the cache replicates).
|
||||||
|
- **The dedicated h2c listener coexists with the LocalDb-sync listener.** A fused admin+driver node with
|
||||||
|
both ports set re-binds its existing HTTP surface exactly **once** and adds both dedicated HTTP/2-only
|
||||||
|
ports — getting this wrong either double-binds (crash) or silently unbinds the AdminUI (the Kestrel
|
||||||
|
`Listen*`-discards-`ASPNETCORE_URLS` trap the LocalDb block already warned about).
|
||||||
|
- **Phase boundary: config *reads* only.** A `FetchAndCache` node still writes its `NodeDeploymentState`
|
||||||
|
ack row to central SQL; `DbHealthProbe` and `EfAlarmConditionStateStore` still touch SQL. Cutting those
|
||||||
|
is Phase 4 — resist folding it in, or the "no SQL" scope becomes unprovable-in-parts.
|
||||||
|
|
||||||
|
On the docker-dev rig the central pair carries `ConfigServe` (`:4055`, committed dev key) and stays
|
||||||
|
hardcoded `Direct` via its admin role; the four site nodes carry `ConfigSource` (endpoints + matching
|
||||||
|
key) hardcoded `FetchAndCache` — mandatory for driver-only nodes since Phase 4, not a flip. There is
|
||||||
|
no `OTOPCUA_CONFIG_MODE` env var in the compose file. See
|
||||||
|
`docs/Configuration.md` §`ConfigSource` / `ConfigServe`, `docs/Redundancy.md` §"Command transport", and
|
||||||
|
`docs/plans/2026-07-22-mesh-phase3-config-fetch-and-cache.md`.
|
||||||
|
|
||||||
|
## Live telemetry transport (`Telemetry` / `TelemetryDial`)
|
||||||
|
|
||||||
|
Per-cluster mesh **Phase 5** (merged `35552a96`; live gate PASSED `f134935f`) added a second
|
||||||
|
transport for the four live-observability channels the AdminUI's `/alerts`, `/script-log`, and
|
||||||
|
`/hosts` panels feed on, selected by `Telemetry:Mode` (node/serve side) and `TelemetryDial:Mode`
|
||||||
|
(central/dial side): `Dps` — the four existing DPS SignalR bridges, **the default**, today's
|
||||||
|
behaviour — or `Grpc`, one gRPC server-streaming contract carrying all four channels as `oneof`
|
||||||
|
event kinds. **The direction is inverted from `ConfigSource`/`MeshTransport`: the driver node hosts
|
||||||
|
the gRPC server (dedicated Kestrel h2c port), and central is the client that dials in** — mirroring
|
||||||
|
ScadaBridge's `SiteStreamService` shape, and required so telemetry survives once the meshes split
|
||||||
|
(Phase 6) and central no longer shares a gossip ring with a site node. Things worth knowing before
|
||||||
|
touching it:
|
||||||
|
|
||||||
|
- **Migrated (4 channels):** `alerts` (`AlarmTransitionEvent`), `script-logs` (`ScriptLogEntry`),
|
||||||
|
`driver-health` (`DriverHealthChanged`), `driver-resilience-status`
|
||||||
|
(`DriverResilienceStatusChanged`) — feeding the exact same central sinks
|
||||||
|
(`IInProcessBroadcaster<...>`, `IDriverStatusSnapshotStore`, `IDriverResilienceStatusStore`) in
|
||||||
|
either mode.
|
||||||
|
- **Deferred, with reasons (do not read "seven observability topics" from the program sketch as the
|
||||||
|
delivered scope):** `redundancy-state` stays on DPS in every mode (pair-local, built from
|
||||||
|
`Cluster.State`, drives ServiceLevel + the Primary gate — genuinely mesh-bound by design, not an
|
||||||
|
observability panel); `deployment-acks` already rides the Phase 2 gRPC command transport
|
||||||
|
(`MeshTransport:Mode=Grpc`, `CentralCommandService.ReportApply`) as a command-plane reply, not
|
||||||
|
telemetry. A third, `fleet-status`, was **DELETED** rather than deferred (2026-08-06, Gitea #542):
|
||||||
|
`FleetStatusBroadcaster` → DPS → SignalR bridge → `FleetStatusHub` had **zero clients end to end**
|
||||||
|
(no browser JS, no `HubConnection` — `/fleet` polls `NodeDeploymentState` in the Config DB), its
|
||||||
|
`DriverHostStatusHeartbeat` input had no production sender, and Phase 6 left its "fleet" as just
|
||||||
|
central's own pair. Fleet visibility is `/fleet` + the four per-node telemetry dials above.
|
||||||
|
- **The node ALWAYS hosts and ALWAYS double-publishes, in both modes.** Every publish seam emits
|
||||||
|
into a node-local `ITelemetryLocalHub` **and** still publishes DPS, unconditionally; the gRPC
|
||||||
|
server binds whenever `Telemetry:GrpcListenPort > 0`, independent of `Telemetry:Mode`. Only
|
||||||
|
`TelemetryDial:Mode` on central actually switches anything: which of the two always-available
|
||||||
|
sources (DPS bridges vs. the `TelemetryDialSupervisor` dialer) feeds the sinks. This is what
|
||||||
|
makes the flip a config change, not a coordinated redeploy — same dark-switch discipline as
|
||||||
|
[MeshTransport](#mesh-command-transport-meshtransport) and [ConfigSource](#config-source-configsource--configserve).
|
||||||
|
- **Authenticated from day one, fail-closed** — a shared node bearer key (`Telemetry:ApiKey` ==
|
||||||
|
`TelemetryDial:ApiKey`), gated by `TelemetryStreamAuthInterceptor`
|
||||||
|
(`CryptographicOperations.FixedTimeEquals`, `PermissionDenied` on mismatch, an unset key rejects
|
||||||
|
every call). This **supersedes** design doc §6.3's "match ScadaBridge's unauthenticated posture
|
||||||
|
for now" — ScadaBridge itself has since closed that gap with this identical interceptor pattern.
|
||||||
|
- **Reconnect story:** central runs one reconnecting dialer per driver node (discovered from
|
||||||
|
`ClusterNode.Host`/`GrpcPort`, refreshed every `TelemetryDial:ContactRefreshSeconds`), immediate
|
||||||
|
first retry then ~5s fixed backoff, generation-stamped so a superseded stream's late error/event
|
||||||
|
is ignored, and it never permanently gives up (observability, not data plane). `TelemetryLocalHub.Evict` drops a removed driver's snapshots (`StopChild`, via `plan.ToStop`; central-side
|
||||||
|
stores are not evicted). The node-local hub
|
||||||
|
replays last-value snapshots for `driver-health`/`driver-resilience-status` on every (re)subscribe
|
||||||
|
so those stores re-prime immediately after a reconnect; `alerts`/`script-logs` are append logs and
|
||||||
|
tolerate the gap. The `/alerts`/`/script-log` pill reflects aggregate stream health (live when ≥1
|
||||||
|
node stream is up) — the same semantics the DPS pill has always had.
|
||||||
|
|
||||||
|
See `docs/Telemetry.md` for the full architecture, `docs/Configuration.md`
|
||||||
|
§`Telemetry`/`TelemetryDial` for the config keys, and
|
||||||
|
`docs/plans/2026-07-23-mesh-phase5-grpc-telemetry-stream.md` for the implementation plan and live
|
||||||
|
gate procedure.
|
||||||
|
|
||||||
|
## LocalDb pair-local store (Phases 1 + 2)
|
||||||
|
|
||||||
|
Every **driver-role** node keeps a consolidated `ZB.MOM.WW.LocalDb` SQLite database (the retired
|
||||||
|
LiteDB `LocalCache` subsystem was deleted as superseded). It holds **three replicated tables**:
|
||||||
|
`deployment_artifacts` + `deployment_pointer` (Phase 1) and `alarm_sf_events` (Phase 2).
|
||||||
|
|
||||||
|
**Phase 1** caches the
|
||||||
|
**deployed-configuration artifact** (chunked, SHA-256-verified, newest-2 retention per cluster) so a
|
||||||
|
driver node can **boot from cache when central SQL Server is unreachable** — `DriverHostActor` writes
|
||||||
|
the cache after each successful apply (`IDeploymentArtifactCache` → `LocalDbDeploymentArtifactCache`,
|
||||||
|
in `Runtime/Deployment/`) and reads it as a boot fallback, flagging running-from-cache. Wired by
|
||||||
|
`AddOtOpcUaLocalDb` in the `hasDriver` branch only (`Host/Configuration/LocalDbRegistration.cs` +
|
||||||
|
`LocalDbSetup.OnReady`, whose DDL→`RegisterReplicated` order is load-bearing); **admin-only nodes
|
||||||
|
never register it.** The cache can optionally replicate to the node's redundant pair peer over the
|
||||||
|
library's gRPC sync — **default-OFF, fail-closed bearer auth** (`LocalDbSyncAuthInterceptor`), gated
|
||||||
|
on `LocalDb:SyncListenPort` (a dedicated Kestrel h2c listener) + `LocalDb:Replication:*`. On the
|
||||||
|
docker-dev rig the **site-a pair** has replication on; central + site-b stay default-OFF (site-b is
|
||||||
|
the pin). ApiKey via `${secret:}`/env, never committed (the rig's dev key is the committed-dev-secret
|
||||||
|
exception).
|
||||||
|
|
||||||
|
**Phase 2** moved the **alarm store-and-forward buffer** off its standalone `alarm-historian.db`
|
||||||
|
and into the same database as `alarm_sf_events`, so a node that dies holding undelivered alarm
|
||||||
|
history no longer takes it to the grave. `SqliteStoreAndForwardSink` became
|
||||||
|
`LocalDbStoreAndForwardSink` (`Core.AlarmHistorian`), and the breaking config key
|
||||||
|
`AlarmHistorian:DatabasePath` was **removed** — it is still read, by `AlarmSfLegacyMigrator`, purely
|
||||||
|
to find a pre-consolidation file to copy across on first boot (renamed `.migrated` after the copy
|
||||||
|
commits). Because the buffer replicates, **only the node holding the Primary role drains it**: the
|
||||||
|
sink takes a `drainGate` delegate, Runtime supplies it from the `IRedundancyRoleView` singleton that
|
||||||
|
`DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, and a gated-off node reports the new
|
||||||
|
`HistorianDrainState.NotPrimary`. Delivery is therefore **at-least-once across a failover, by
|
||||||
|
design** (no dedup layer). Row ids are a SHA-256 of the event payload rather than a GUID, so the
|
||||||
|
same event accepted on both nodes — which happens in every boot window, since
|
||||||
|
`HistorianAdapterActor` default-writes while its role is unknown — converges to one row. On the
|
||||||
|
docker-dev rig the site-a pair and site-b both run `AlarmHistorian__Enabled=true` against a
|
||||||
|
deliberately unresolvable gateway endpoint, so the buffer is always a live, non-draining queue.
|
||||||
|
|
||||||
|
**The library pin is `0.3.4`** (`Directory.Packages.props`, both `ZB.MOM.WW.LocalDb` and
|
||||||
|
`.Replication`), bumped from `0.2.1` on 2026-09-03 (was itself bumped from `0.1.3` on 2026-08-15).
|
||||||
|
The 0.2.1→0.3.4 bump is wire-compatible with 0.2.x, and the on-disk bookkeeping schema v2 → v3
|
||||||
|
upgrades in place on open, so no rig or volume action was needed. `RegisterReplicated(string)` is
|
||||||
|
unchanged and everything 0.2.x/0.3.x added is additive, **except** that `RegisterReplicated` (0.3.3)
|
||||||
|
now throws `LocalDbRegistrationException` for a table that is the parent of a row-mutating ON
|
||||||
|
DELETE FK — none of this repo's four replicated tables have such an FK, so this did not fire.
|
||||||
|
0.3.0 made the snapshot-resync flag **per-table** (see the ⚠️ below, now corrected); 0.3.2 added
|
||||||
|
validated config `Replication:KeepAlivePingDelay`/`KeepAlivePingTimeout` (defaults 60s/20s, left
|
||||||
|
unset here) and a public `LocalDbOplogGapException`, neither yet wired to a call site. **Its two
|
||||||
|
new APIs — `DeregisterReplicated` and `RegisterReplicated(table, baselineExistingRows:)` — are now
|
||||||
|
both used** (2026-08-18); the bullets below are the shape that landed, not the bump itself. Two
|
||||||
|
things are worth knowing:
|
||||||
|
|
||||||
|
- **`MaxBatchSize` no longer means what the rig's `16` was chosen to mean.** 0.2.0 made batching
|
||||||
|
**byte-budgeted** — `LocalDb:Replication:MaxBatchBytes` (2 MB default, left unset here, sized
|
||||||
|
under the 4 MB gRPC cap) bounds the wire message. `MaxBatchSize` is demoted to a secondary row
|
||||||
|
cap **and** the DB read-page size in `OplogStore.ReadBatchAboveAsync`/`SnapshotStreamer`, which
|
||||||
|
materialise a whole page in memory *before* the byte split runs. So the site-a pair keeps
|
||||||
|
`16` — it now bounds that transient allocation (≈2.7 MB at ≈171 KB artifact chunk rows), not the
|
||||||
|
message size. Every comment and doc row that called it "row-count-only against gRPC's 4 MB cap"
|
||||||
|
was corrected with the bump; do not restore that rationale.
|
||||||
|
- **Registration FOLLOWS the replication config — it is no longer unconditional (2026-08-18).**
|
||||||
|
`LocalDbSetup.OnReady` reads one predicate, `LocalDbReplicationPolicy.CapturesChanges`
|
||||||
|
(via `LocalDbSetup.ReplicationIsConfigured`):
|
||||||
|
**`LocalDb:Replication:PeerAddress` OR `LocalDb:Replication:ApiKey` is non-blank.** The **OR is
|
||||||
|
not belt-and-braces** — only the dialling half of a pair sets `PeerAddress`, so keying on it
|
||||||
|
alone would strip capture from every *passive* node (site-a-2 carries `ApiKey` +
|
||||||
|
`LocalDb:SyncListenPort` and nothing else) and the pair would converge in one direction only,
|
||||||
|
silently. ⚠️ **`SyncListenPort` deliberately does NOT join the OR:** the passive endpoint is
|
||||||
|
fail-closed on the key, so a port without an `ApiKey` rejects every inbound stream and has
|
||||||
|
nothing to capture for.
|
||||||
|
- ⚠️ **The alarm DRAIN gate uses a different predicate** — `LocalDbReplicationPolicy.SharesRowsWithPeer`:
|
||||||
|
`ApiKey` **and** (`PeerAddress` **or** `LocalDb:SyncListenPort > 0`), i.e. "a peer actually holds these
|
||||||
|
rows". Capture must be wider than the drain gate (a dialling-only node still captures); a node with no
|
||||||
|
peer must still drain, so the two predicates are deliberately not the same.
|
||||||
|
The **passive** half (no `PeerAddress`) cannot name its peer, so it stands down for its **mesh partner**
|
||||||
|
(the other member of its `cluster-{ClusterId}` role); a legacy passive half with no mesh role still
|
||||||
|
drains alongside its partner (duplicate historian rows). ⚠️ Before a node has joined its mesh the
|
||||||
|
published Primary verdict (and the live data-plane gate, which must stay the same decision) **allows** —
|
||||||
|
for seconds, up to ~25 s under the bootstrap guard — so a cold-starting node can briefly co-hold the
|
||||||
|
Sparkplug primary-host claim with its Primary peer.
|
||||||
|
- **Configured ⇒ `RegisterReplicated(table, baselineExistingRows: true)`**, per table, in the
|
||||||
|
same load-bearing DDL→register order. Baselining is what makes enabling replication on a node
|
||||||
|
that has been running *without* it actually converge: capture is CDC and the snapshot streamer
|
||||||
|
pages from `__localdb_row_version`, so pre-registration rows were in neither and a late opt-in
|
||||||
|
used to replicate only writes made after the restart. It seeds the ledger at the LWW floor
|
||||||
|
(HLC `0`, this node's id) and flags a resync — a baselined row loses to any genuine remote
|
||||||
|
write of the same key and wins only where the peer has no version at all. It is idempotent, so
|
||||||
|
it is free on every boot after the first. As of `0.3.0` (this repo pins `0.3.4`) the snapshot
|
||||||
|
flag is **per-table**, not per-database — baselining one table no longer streams every
|
||||||
|
registered table in both directions. (Prior to `0.3.0`, on the `0.2.1` pin this repo carried
|
||||||
|
until 2026-09-03, the flag was per-database and baselining anything streamed *every*
|
||||||
|
registered table in full; the old advice to trim `alarm_sf_events` before enabling replication
|
||||||
|
no longer applies at the current pin.)
|
||||||
|
- **Not configured ⇒ `DeregisterReplicated(table)` per table**, an idempotent boot-time
|
||||||
|
self-heal: it drops the capture triggers an earlier (unconditional) build left in the file and
|
||||||
|
prunes that table's oplog + row-version rows. Merely declining to register would leave every
|
||||||
|
pre-2026-08-18 file capturing forever — two INSERTs plus a `json_object` of the whole row
|
||||||
|
inside every write transaction — on precisely the node that has no peer to capture for. The
|
||||||
|
call returns `true` when it removed something, so the Information line fires **once, and only
|
||||||
|
when it actually cleaned**; a default-OFF node's ordinary boot stays silent. The logger is
|
||||||
|
Serilog's static `Log.Logger` (this runs inside the `AddZbLocalDb` singleton factory, where
|
||||||
|
resolving one from DI would nest a resolution inside a singleton construction; `Program.cs`
|
||||||
|
assigns it before anything resolves `ILocalDb`), with an optional parameter so tests observe
|
||||||
|
the line rather than infer it.
|
||||||
|
- ⚠️ **PAIR SYMMETRY: flipping registration is a BOTH-NODES-TOGETHER change.** The sync handshake
|
||||||
|
compares registered-table digests **fail-closed**, so a node that deregisters a table its peer
|
||||||
|
still replicates stops syncing with a schema mismatch. Enabling replication on an established
|
||||||
|
pair means setting the config and **restarting both nodes** — do it on both, or on neither.
|
||||||
|
- **Rollout note:** the only replicating pair in the fleet is docker-dev **site-a**, and it was
|
||||||
|
enabled from a fresh pair, so the impact of the old gap was low — central and site-b are
|
||||||
|
default-OFF and simply shed their unused triggers on their next boot. `LocalDbSetupTests`
|
||||||
|
pins both branches (registered-table set on a configured node, empty set + zero triggers +
|
||||||
|
the HLC-0 baseline ledger entry on the other) and the DDL→register ordering, unchanged.
|
||||||
|
|
||||||
|
See `docs/Configuration.md` (`LocalDb` section), `docs/Redundancy.md` (pair-local config
|
||||||
|
cache), `docs/AlarmHistorian.md`, and the runbook
|
||||||
|
`docs/operations/2026-07-20-localdb-pair-replication.md`.
|
||||||
|
|
||||||
|
## Phase 4 — cut the driver-side ConfigDb connection
|
||||||
|
|
||||||
|
Per-cluster mesh **Phase 4** finished the job Phase 3 started: a **driver-only** node (`Cluster:Roles`
|
||||||
|
has `driver`, not `admin`) now boots with **no `ConfigDb` connection string at all**. `Program.cs`
|
||||||
|
gates `AddOtOpcUaConfigDb` (and its health check) on `hasAdmin`; a fused `admin,driver` node is
|
||||||
|
unaffected (it keeps ConfigDb via its admin role). Consequences:
|
||||||
|
|
||||||
|
- **`ConfigSource:Mode` must be `FetchAndCache` on a driver-only node** — `ConfigSourceOptionsValidator`
|
||||||
|
fails host start if such a node is left on `Direct` (there is nothing to read Direct from). A fused
|
||||||
|
node may stay `Direct`.
|
||||||
|
- **ServiceLevel semantics change — client-visible.** With no ConfigDb, `DbHealthProbeActor` is not
|
||||||
|
spawned (`ServiceCollectionExtensions.WithOtOpcUaRuntimeActors` skips it when the resolved db
|
||||||
|
factory is null), and `OpcUaPublishActor` runs `dbLess`: `DbReachable` is fed constant `true` and
|
||||||
|
`Stale` comes from the redundancy-snapshot age alone. A healthy driver-only node therefore publishes
|
||||||
|
**240** (or **250** as driver Primary) even with central SQL unreachable — the survive-alone posture
|
||||||
|
— where a `Direct`/DB-backed node in the same state drops to 100/0. `ServiceLevelCalculator` itself
|
||||||
|
is unchanged; only the inputs a DB-less node feeds it changed. See `docs/Redundancy.md` §"DB-less
|
||||||
|
(driver-only) nodes".
|
||||||
|
- **Scripted-alarm Part 9 condition state moved to LocalDb.** `LocalDbAlarmConditionStateStore`
|
||||||
|
(replicated `alarm_condition_state` table, pair-local like the Phase-2 alarm S&F buffer) replaces
|
||||||
|
`EfAlarmConditionStateStore` on every driver-role node, fused central included. The DB-backed
|
||||||
|
`ScriptedAlarmState` table **was dropped** (Task 9, `3a590a0c`, migration
|
||||||
|
`20260723183050_DropScriptedAlarmStateTable`); `EfAlarmConditionStateStore` is deleted.
|
||||||
|
- **Central persists deploy acks.** `DriverHostActor` no longer writes `NodeDeploymentState` to SQL on
|
||||||
|
a driver-only node — `ConfigPublishCoordinator.PersistNodeAck` (already fed by every ApplyAck) is
|
||||||
|
the system of record.
|
||||||
|
- **LDAP group→role DB grants don't reach driver-only nodes.** They register the empty
|
||||||
|
`NullLdapGroupRoleMappingService` (no ConfigDb to read `LdapGroupRoleMapping` from), so
|
||||||
|
`OtOpcUaGroupRoleMapper` falls back to the `Security:Ldap:GroupToRole` appsettings baseline only —
|
||||||
|
the same rows a driver node never received via config either, so this is not a regression.
|
||||||
|
|
||||||
|
Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes run ConfigDb-free) and
|
||||||
|
Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
|
||||||
|
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/**/*Acl*"
|
||||||
|
- "docs/security.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Data-plane authentication (API keys), node ACL enforcement, anonymous principal, AdminUI LDAP
|
||||||
|
|
||||||
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
||||||
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
||||||
|
|
||||||
|
## Authentication: API keys on the data plane, LDAP on AdminUI (#520)
|
||||||
|
|
||||||
|
⚠️ **Changed 2026-07-31. The OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine,
|
||||||
|
so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an
|
||||||
|
`otua_<keyId>_<secret>` credential as the password. `ApiKeyOpcUaUserAuthenticator`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/`) implements `IOpcUaUserAuthenticator`: one indexed lookup
|
||||||
|
on `keyId` plus one peppered HMAC-SHA256, `FixedTimeEquals`, **no network I/O**. The key resolves to a
|
||||||
|
**principal**; `NodeAcl.Principal` rows grant it permissions. `LdapOpcUaUserAuthenticator` is
|
||||||
|
**deleted**, along with four `Security:Ldap` keys that existed only for it (`AuthTimeoutMs`,
|
||||||
|
`MaxConcurrentAuthentications`, `OutageFailureThreshold`, `OutageCooldownSeconds`) — they bounded an
|
||||||
|
LDAP bind running inline in the SDK's synchronous, `SessionManager`-lock-holding impersonation
|
||||||
|
callback, and nothing on the remaining LDAP path has that property.
|
||||||
|
|
||||||
|
**LDAP remains, for AdminUI login only** (`Security:Ldap`, `OtOpcUaLdapAuthService` →
|
||||||
|
`ILdapAuthService`, group→role mapping via `GroupToRole` + `LdapGroupRoleMapping`). Do not "restore"
|
||||||
|
the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is there for the AdminUI.
|
||||||
|
|
||||||
|
Two things to know before relying on any of this:
|
||||||
|
|
||||||
|
- ⚠️ **Node ACLs are enforced only behind `Security:Acl:Enabled` (default `false`).** With the flag
|
||||||
|
**off**, a key's grants collapse into the two server-wide role gates (`WriteOperate` on Value writes,
|
||||||
|
`AlarmAck` on Part 9 methods), **only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper`
|
||||||
|
deliberately under-grants, because honouring a narrower grant's flags while unable to honour its
|
||||||
|
*scope* would turn "WriteOperate on Equipment X" into server-wide write authority — and **read,
|
||||||
|
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
|
||||||
|
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
|
||||||
|
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
|
||||||
|
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
|
||||||
|
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
|
||||||
|
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
|
||||||
|
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
|
||||||
|
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
|
||||||
|
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
|
||||||
|
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
|
||||||
|
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
|
||||||
|
deployment carries it — the flag-on path always behaved this way.
|
||||||
|
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
|
||||||
|
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
|
||||||
|
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
|
||||||
|
nothing. The load-bearing test is therefore an **allow** — a session carrying *no roles* writing
|
||||||
|
successfully on the strength of its grant. A deny-only test passes against a build where the ACL is
|
||||||
|
never consulted, because the role gate denies too.
|
||||||
|
- **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a second wiring at the publish
|
||||||
|
actor, and the state owns its `PermissionTrieCache` rather than being handed one. An uninstalled
|
||||||
|
trie denies everything, which is indistinguishable from working enforcement until someone checks
|
||||||
|
that an ALLOW is possible at all.
|
||||||
|
- **A deploy reaches sessions that are already open**, in both directions: each decision stamps the
|
||||||
|
*current* generation rather than the one the session activated under, and re-checks the
|
||||||
|
authenticating key — so revoking a key or a grant stops an open session immediately instead of at
|
||||||
|
the client's next reconnect. On a driver-only node the artifact is the only revocation channel.
|
||||||
|
- `UserAuthorizationState` is built **per decision walk, not per session** — and since the hot-path
|
||||||
|
change it is allocated only on a cache **miss**, so a cache hit never constructs one. Its LDAP-shaped
|
||||||
|
freshness/staleness predicates are switched off EXPLICITLY (`AuthCacheMaxStaleness = MaxValue`).
|
||||||
|
⚠️ Relying on "the stamp is always fresh" was not safe: `MembershipResolvedUtc` comes from
|
||||||
|
`DataPlaneAuthorizer`'s `TimeProvider` while `IsStale` is evaluated against
|
||||||
|
`TriePermissionEvaluator`'s, so **any divergence between the two clocks denies every request** with a
|
||||||
|
verdict indistinguishable from "no grant matched". Found by a test clock two hours ahead.
|
||||||
|
- **Stage 6's hot seams (read/browse/subscribe) go through a per-`(session, node, operation, realm)`
|
||||||
|
decision cache**, cleared wholesale when the generation advances. It is **sharded per API key**
|
||||||
|
(`ApiKeyId ?? Principal` — despite the type name, sessions sharing one key share one shard and one
|
||||||
|
budget), so a client crawling a large tree past the per-key ceiling evicts only that key's shard and
|
||||||
|
not every other key's decisions. ⚠️ It caches the **grant walk only** — key revocation and expiry are
|
||||||
|
re-checked **before** the cache lookup on every call, because expiry moves with the clock rather than
|
||||||
|
with a deployment.
|
||||||
|
- **Browse needs ancestor visibility, and it must NOT be a synthesized grant on the ancestors.** A
|
||||||
|
grant deep in the tree is reached by browsing the nodes above it, and the trie inherits downward
|
||||||
|
only, so `PermissionTrieNode.BrowseTransitPrincipals` marks the path. Planting a real `Browse` grant
|
||||||
|
on each ancestor instead would be collected by the additive downward walk and leak `Browse` onto
|
||||||
|
every sibling subtree beneath that ancestor. ⚠️ **The topmost ancestor is the single shared root
|
||||||
|
folder** (`s=OtOpcUa`, raw namespace) that BOTH realm trees hang under — its identifier is a display
|
||||||
|
name, not a path, so `DataPlaneAuthorizer.BuildScope` maps it onto the trie's **zero-segment
|
||||||
|
realm-root scope** (`V3NodeIds.SharedRootFolderId`, realm-qualified: in the UNS namespace that
|
||||||
|
identifier is an ordinary Area), and its Browse considers transit in **either** realm because it
|
||||||
|
fronts both trees. Before this every narrow Browse grant was unreachable by navigation — the Browse
|
||||||
|
gate denies the whole call when its source node is denied, so clients were stopped at the front door.
|
||||||
|
- **Revocation reaches live subscriptions** (stage 6): every other operation re-asks per request, but a
|
||||||
|
subscription is authorized once and then streams — and streaming is how machine consumers actually
|
||||||
|
read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied
|
||||||
|
item is queued `BadUserAccessDenied` and then **detached from its `MonitoredNode2` fan-out — NOT set
|
||||||
|
`MonitoringMode.Disabled`**: disabling discards the item's queued notifications, so the client is
|
||||||
|
silenced without ever being told why (caught only by the over-the-wire test). Detaching is also
|
||||||
|
reversible, so restoring the grant resumes the feed with no client action. ⚠️ **`Changed` is no longer
|
||||||
|
the only trigger (arch-review 03/S19):** `AclRevalidationTimer`, armed by `OtOpcUaServerHostedService`
|
||||||
|
at `Security:Acl:RevalidationIntervalSeconds` (default 60 s, `0` disables, inert while
|
||||||
|
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
|
||||||
|
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
|
||||||
|
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
|
||||||
|
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
|
||||||
|
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
|
||||||
|
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
|
||||||
|
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
|
||||||
|
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
|
||||||
|
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
|
||||||
|
**Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The
|
||||||
|
`SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`);
|
||||||
|
each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one
|
||||||
|
Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the
|
||||||
|
condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The
|
||||||
|
root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the
|
||||||
|
ungated path, and it is the only one that served an item created before the alarm was deployed (the
|
||||||
|
SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is
|
||||||
|
**implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no
|
||||||
|
pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep
|
||||||
|
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
|
||||||
|
because conditions report their Part 9 method audit events through themselves.
|
||||||
|
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
|
||||||
|
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
|
||||||
|
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
|
||||||
|
protected `SubscribeToEvents` override that no-ops all-events items is what
|
||||||
|
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
|
||||||
|
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
|
||||||
|
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
|
||||||
|
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
|
||||||
|
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
|
||||||
|
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
|
||||||
|
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
|
||||||
|
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
|
||||||
|
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
|
||||||
|
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
|
||||||
|
event type fails closed. Auditing is off by default (never configured in this repo).
|
||||||
|
- **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`,
|
||||||
|
`NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and
|
||||||
|
clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node,
|
||||||
|
event subscription and HistoryRead is still gated. `docs/security.md`.
|
||||||
|
- **Equipment-node writes: `EvaluateEquipmentWriteStructure` is the ONLY type check.** `OnWriteValue` bypasses
|
||||||
|
the SDK's own type check on every writable equipment node, so `OtOpcUaNodeManager.EvaluateEquipmentWriteStructure`
|
||||||
|
(plus `EvaluateEquipmentWriteRange`) is the whole gate. Scalars are deliberately lenient (drivers
|
||||||
|
range-check; ScadaBridge sends Double to a Float node). **Array writes** (#610): a numeric array of another
|
||||||
|
element type is **converted, checked, element by element** to the node's element type (ScadaBridge writes
|
||||||
|
CLR `int[]`/`double[]` lists) — any out-of-range, fractional-to-integer or NaN/∞ element refuses the whole
|
||||||
|
write `BadTypeMismatch` with nothing sent, so a `UInt32` > `Int32.MaxValue` can never reach a Galaxy Int32
|
||||||
|
attribute as reinterpreted bits; non-numeric mismatches are refused (`Byte[]` ≈ ByteString); wrong length ⇒
|
||||||
|
`BadTypeMismatch`; an array to a **scalar** node is refused only on Modbus/S7 (`EnsureVariable`'s
|
||||||
|
`refuseArrayOnScalar` — pass-through drivers keep their browse-committed scalar-shaped array tags writable;
|
||||||
|
a new sink wrapper must forward that argument, and the reflection guard checks only that the call arrives);
|
||||||
|
`IndexRange` writes ⇒ `BadWriteNotSupported`, `ValueRank > 1` out of scope. An
|
||||||
|
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus bounded
|
||||||
|
by `maxRegistersPerWrite`/coil cap, S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||||
|
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
|
||||||
|
resize the Galaxy attribute); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
|
||||||
|
ArrayWritable)` is projected onto the raw node and every UNS mirror.
|
||||||
|
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||||
|
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||||
|
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
|
||||||
|
the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may
|
||||||
|
**not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather
|
||||||
|
than admitting with no roles.
|
||||||
|
- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots
|
||||||
|
the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is
|
||||||
|
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
|
||||||
|
node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on
|
||||||
|
`IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment
|
||||||
|
that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-activation
|
||||||
|
read, wrapped — see below); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The asymmetry is
|
||||||
|
ACTIVATION-scoped, not session-scoped — this line used to say "immediate on a ConfigDb node" and that was
|
||||||
|
wrong.** Clearing `Enabled` in the ConfigDb stops the **next session activation** on an admin/fused node
|
||||||
|
(bounded by `Security:ApiKeys:StoreCacheSeconds`, default 5 s) and takes until the next deployment on a
|
||||||
|
driver-only one — but an **already-open** session re-checks its key against the in-memory *artifact
|
||||||
|
snapshot* (`DataPlaneAuthorizer.Evaluate` → `snapshot.FindUsableKey`), never against `IApiKeyStore`, so on
|
||||||
|
**both** node kinds an `Enabled` revocation reaches an open session only at the next deployment.
|
||||||
|
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
|
||||||
|
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
|
||||||
|
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
|
||||||
|
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
|
||||||
|
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
|
||||||
|
two role gates (#563) but not reads or subscriptions.
|
||||||
|
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
|
||||||
|
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
|
||||||
|
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
|
||||||
|
store — and only it, never `ArtifactApiKeyStore`, which is two in-memory dictionary probes — with
|
||||||
|
`Security:ApiKeys:StoreTimeoutMs` (2000; exceeding it **denies**, via the authenticator's existing
|
||||||
|
catch-all) plus a `StoreCacheSeconds` (5) read-through cache. Faults and timeouts are never cached, a
|
||||||
|
cached hit whose `ExpiresUtc` has passed is re-read rather than served, and a timeout logs a distinct
|
||||||
|
Warning + `otopcua.opcua.apikey_store.bounded{outcome=timeout}`. Both knobs at `0` restore the old
|
||||||
|
behaviour exactly.
|
||||||
|
- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its
|
||||||
|
bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from
|
||||||
|
hash bytes would install a *lower* number about half the time and the cache would go on serving the
|
||||||
|
previous deployment's ACLs with nothing logged. Pinned by
|
||||||
|
`DataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases`.
|
||||||
|
- Note `OtOpcUaServerHostedService` takes `IOpcUaUserAuthenticator` **and** `IDataPlaneAuthoritySource`
|
||||||
|
as **required** ctor parameters — the built-in DI container does not fall back to a default value for
|
||||||
|
an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a
|
||||||
|
node with neither role) or it does not boot.
|
||||||
|
|
||||||
|
**Grants are authored through a scope picker, not free text (stage 7).** `/clusters/{id}/acls` opens
|
||||||
|
`AclScopePickerModal` over the cluster's raw + UNS trees, and selecting a node supplies `ScopeKind`
|
||||||
|
**and** `ScopeId` together — so `RawDevice` + a TagId, a cross-cluster scope, a `Cluster` grant carrying
|
||||||
|
an id, and the retired `FolderSegment` kind are all unauthorable by construction rather than merely
|
||||||
|
rejected later. ⚠️ **A `Cluster` row must produce a NULL `ScopeId`** — the deploy gate rejects one that
|
||||||
|
carries an id (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
|
||||||
|
author rows that cannot be deployed. `IAclScopeService.CheckScopeAsync` mirrors
|
||||||
|
`DraftValidator.ValidateNodeAclScopes` rule for rule and **blocks the save** when a scope resolves to
|
||||||
|
nothing (re-checked on submit, since a row loaded unchanged is exactly where the operator never touches
|
||||||
|
the picker); the list page marks such rows `unresolved`. **UNS tags are deliberately not offered** — a
|
||||||
|
UNS signal is a `UnsTagReference`, and a `Tag` grant already covers the raw node *and* every UNS
|
||||||
|
projection of it. The picker is `AclScopeTree`, a separate component from `RawTree`, which already
|
||||||
|
carries two modes.
|
||||||
|
|
||||||
|
**All eight stages are done** (`d4389f57` enforcement, stage 7 authoring after it), each live-gated on
|
||||||
|
docker-dev. The rationale for every decision above is in
|
||||||
|
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide.
|
||||||
|
|
||||||
|
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/Drivers/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/**"
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/**"
|
||||||
|
- "tests/Drivers/**"
|
||||||
|
- "docs/DriverLifecycle.md"
|
||||||
|
- "docs/drivers/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Drivers: rediscovery signal, comms-loss watchdog + quality contract, AB native transport, per-driver notes
|
||||||
|
|
||||||
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
||||||
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
||||||
|
|
||||||
|
### Change Detection
|
||||||
|
|
||||||
|
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
|
||||||
|
|
||||||
|
**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
|
||||||
|
`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
|
||||||
|
load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
|
||||||
|
the same driver object. A raise is recorded and rides the driver-health snapshot
|
||||||
|
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
|
||||||
|
**"re-browse" chip**.
|
||||||
|
|
||||||
|
⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
|
||||||
|
tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
|
||||||
|
commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
|
||||||
|
records.
|
||||||
|
|
||||||
|
Two gotchas if you touch this:
|
||||||
|
|
||||||
|
- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in
|
||||||
|
that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the
|
||||||
|
operator. Pinned by `DriverInstanceActorRediscoverySignalTests`.
|
||||||
|
- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on
|
||||||
|
every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests
|
||||||
|
use a stub with stable health for exactly this reason.
|
||||||
|
|
||||||
|
**#507 is closed as superseded, and the injection path is deleted.** Its retained code read
|
||||||
|
`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3
|
||||||
|
(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log
|
||||||
|
line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
|
||||||
|
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
|
||||||
|
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop
|
||||||
|
(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18
|
||||||
|
`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft
|
||||||
|
(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw`
|
||||||
|
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
|
||||||
|
the actor.
|
||||||
|
|
||||||
|
**`IRediscoverable` is the ONLY tag-change signal, and four drivers do not raise it.** A periodic
|
||||||
|
tag-set drift check briefly existed (shipped `e77c8a35`, **removed** by Gitea **#523**): every
|
||||||
|
`SupportsOnlineDiscovery` driver was re-browsed on a 5-minute timer and the result diffed against its
|
||||||
|
authored tags. It was taken back out because it generated recurring, **unconfigurable** (interval was a
|
||||||
|
constructor parameter with no appsettings key), **never-live-gated** outbound traffic to real
|
||||||
|
PLCs/CNCs/Agents — how often to browse someone's plant equipment is an operational decision, and the
|
||||||
|
design treated it as an implementation detail.
|
||||||
|
|
||||||
|
⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but
|
||||||
|
implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download
|
||||||
|
or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT,
|
||||||
|
MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an
|
||||||
|
event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a
|
||||||
|
deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`,
|
||||||
|
`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
|
||||||
|
`DiscoveryStreamIncludesAuthoredTags`.
|
||||||
|
|
||||||
|
**`IHostConnectivityProbe` is consumed (Gitea #521, closed).** `DriverInstanceActor` subscribes
|
||||||
|
`OnHostStatusChanged`, pulls `GetHostStatuses()` fresh, and the host set rides `DriverHealthChanged` to the
|
||||||
|
AdminUI `/hosts` page — no SQL: the `DriverHostStatus` table is dropped, and so is
|
||||||
|
`DriverInstanceResilienceStatus` (#524; the live data goes over the `driver-resilience-status` telemetry
|
||||||
|
channel only). **Multi-host dispatch is wired too:** `DriverInstanceActor` resolves the breaker/host key
|
||||||
|
per call via `IPerCallHostResolver` (Modbus, FOCAS, AbLegacy, TwinCAT, AbCip).
|
||||||
|
|
||||||
|
### Comms-loss detection: `GetHealth()` is load-bearing (Gitea #527, closed)
|
||||||
|
|
||||||
|
Until #527, **`DriverInstanceActor.DisconnectObserved` had no production sender.** The only paths into
|
||||||
|
`Reconnecting` were a failed *initial* connect and an operator's Force Reconnect, so a driver that connected
|
||||||
|
and then lost its device stayed `Connected` indefinitely — publishing Bad for every tag, never
|
||||||
|
re-initialising, and **not recovering when the device returned** (live-confirmed: a Modbus driver against a
|
||||||
|
stopped fixture needed a node restart). `PublishHealthSnapshot` read `GetHealth()` for *reporting only*.
|
||||||
|
|
||||||
|
The `Connected` health tick now runs a watchdog that self-sends `DisconnectObserved` when a driver reports a
|
||||||
|
non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. Things to know before touching it:
|
||||||
|
|
||||||
|
- ⚠️ **The conjunction is the design — "non-Healthy for N polls" is wrong and was the issue's own proposal.**
|
||||||
|
`Degraded` means *connected but some data is bad*, and several drivers write health **per tag inside the
|
||||||
|
read loop** (`ModbusDriver.ReadAsync`), so one unreadable address latches `Degraded` forever against a
|
||||||
|
perfectly reachable device. State-only tripping would reconnect that driver every 90 s indefinitely.
|
||||||
|
`LastSuccessfulRead` is the honest evidence — preserved across `Degraded`/`Faulted` writes, advanced by any
|
||||||
|
success. Each conjunct has a test that goes red when the *other* is deleted.
|
||||||
|
- **The mechanism rests on every driver reaching a non-Healthy state on sustained failure.** Surveyed across
|
||||||
|
all 12: nine poll drivers via `Degraded`, MQTT via live connection state, Galaxy via its reconnect
|
||||||
|
supervisor, OpcUaClient via a bad session keep-alive (#670 — it used to stay `Healthy` forever; its SDK
|
||||||
|
`SessionReconnectHandler` is deleted, a lost session publishes `BadNoCommunication` once per outage and the
|
||||||
|
watchdog's re-init is the recovery; good keep-alives + notifications advance `LastSuccessfulRead`). `CalculationDriver` reports `Healthy` while running and so can never trip — correct, as it has
|
||||||
|
no transport. Re-check this if a new driver lands.
|
||||||
|
- **The reconnect retry MUST tear down before re-initialising — FIXED in both drivers, keep it that way.**
|
||||||
|
`ModbusDriver` (`_transport`) and `MqttDriver` (`_connection`) used to overwrite an `IAsyncDisposable`
|
||||||
|
field inside `InitializeAsync` without disposing the old one, so re-initialising without `ShutdownAsync`
|
||||||
|
leaked a socket per attempt — ~360 for an hour-long outage at the 10 s retry interval. Both now dispose
|
||||||
|
first: `ModbusDriver.InitializeCoreAsync` releases `_transport` **and** the `_probeCts`/`_reprobeCts`
|
||||||
|
probe loops before rebuilding them (`c3970a2e` + the lifecycle gate in `409a58b6`), deliberately *not*
|
||||||
|
via the shared `TeardownAsync()` because that would also drop `_poll` and every active subscription with
|
||||||
|
nothing to re-drive them; `MqttDriver.InitializeCoreAsync` calls its (idempotent) `TeardownAsync()` first,
|
||||||
|
which reaches `_connection` and the primary-host connection (`2b4b3fcb`). The path is no longer latent —
|
||||||
|
the comms-loss watchdog reaches it, not just a manual Force Reconnect — so a new driver, or a new
|
||||||
|
`IAsyncDisposable` field on these two, must repeat the dispose-before-overwrite.
|
||||||
|
- The threshold is hardcoded (`DefaultCommsLossTimeout`), matching `HealthPollInterval` /
|
||||||
|
`DefaultReconnectInterval`. Unlike the #523 drift detector this generates **no** traffic on a healthy
|
||||||
|
system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped
|
||||||
|
reading.
|
||||||
|
- **Fast path for DEFINITIVE losses (#676):** a driver implementing the opt-in `IDriverConnectionLossSource` raises
|
||||||
|
`ConnectionLost` (once per connection) when its protocol proves the connection can never recover in place — OpcUaClient:
|
||||||
|
keep-alive `BadSessionIdInvalid`/`BadSessionClosed`/`BadSessionNotActivated`/`BadServerHalted`, a SUSTAINED closed
|
||||||
|
connection (2nd `BadSecureChannelClosed`/`BadConnectionClosed` in one outage — what a real process stop produces; the
|
||||||
|
SDK never reopens it) or a subscription the SERVER reported dropped (its publishing-stall heuristic does not raise) — and `DriverInstanceActor` (attach PreStart, detach PostStop) turns it into the same disconnect only
|
||||||
|
while Connected; ambiguous failures (timeouts, `BadNoCommunication`, a stalled socket) must never raise and stay with
|
||||||
|
the watchdog's conjunction. `docs/DriverLifecycle.md`.
|
||||||
|
|
||||||
|
- **What comms loss now does downstream — one contract, six seams (archreview R3-02).** "A value consumer
|
||||||
|
never reports a quality better than its source's" is stated once in `docs/DriverLifecycle.md` §"The
|
||||||
|
quality contract" and implemented by `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`). Three
|
||||||
|
things changed with it, all client-visible:
|
||||||
|
- ⚠️ **Value nodes of a comms-lost driver now go Bad.** `DriverHostActor.FanCommsLossToValueNodes`
|
||||||
|
stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained
|
||||||
|
server-side**, via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host
|
||||||
|
holds no value cache, so only the node can keep the value). ⚠️ Retention is **not client-observable**:
|
||||||
|
the SDK's `NodeState.ReadAttribute` nulls the value of any Bad-status read/sample, so clients see
|
||||||
|
`Value = null` + the Bad status; the retained value is what the next Good re-publish and the
|
||||||
|
`(value, status)` dedup compare against. #481 had wired comms loss to the mux and to Part 9
|
||||||
|
conditions and stopped one seam short of the external data plane. A consumer that treats Bad as "drop
|
||||||
|
the tag" (ScadaBridge) will see more Bads — that is the fix working.
|
||||||
|
- **Nothing Good is written on reconnect** — recovery is the driver's own re-publish (the poll engine's
|
||||||
|
`forceRaise`, MQTT's new initial push). Instead the host **audits**: refs still latched 30 s after a
|
||||||
|
reconnect log a Warning and increment `otopcua.driver.comms_latched_refs`. Non-zero on that counter
|
||||||
|
means a driver reconnects without re-publishing.
|
||||||
|
- **Every dedup on the recovery path is keyed on (value, status), not value.** A device that comes back
|
||||||
|
usually comes back holding the same reading, so a value-only dedup swallows the recovery and the node
|
||||||
|
stays Bad forever — the v3-gate trap, now closed in the Calculation driver, the virtual-tag actor and
|
||||||
|
(via a connection-loss stale) MQTT's plain-mode suppression. **Any new consumer of driver values must
|
||||||
|
repeat this**; a regression test for it must replay the IDENTICAL value or it proves nothing.
|
||||||
|
- Consequences per seam: MQTT/Sparkplug stale on connection loss and push cached snapshots on every
|
||||||
|
(re)subscribe (`docs/drivers/Mqtt.md`); the Calculation driver and virtual tags **hold** while any
|
||||||
|
input is Bad and inherit the worst input's sub-code, evaluate through Uncertain, and hand scripts the
|
||||||
|
input's real `StatusCode`/`SourceTimestampUtc` (`docs/drivers/Calculation.md`, `docs/ScriptEditor.md`)
|
||||||
|
— so a script that relied on receiving `null` for a dead input no longer runs at all while it is Bad.
|
||||||
|
|
||||||
|
See `docs/DriverLifecycle.md` §"The comms-loss watchdog" and §"The quality contract".
|
||||||
|
|
||||||
|
## AB drivers: native transport (`Transport`)
|
||||||
|
|
||||||
|
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
|
||||||
|
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
|
||||||
|
**constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI
|
||||||
|
"Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never
|
||||||
|
re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07):
|
||||||
|
|
||||||
|
- **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place,
|
||||||
|
`AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting
|
||||||
|
**`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`,
|
||||||
|
`AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally
|
||||||
|
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
|
||||||
|
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
|
||||||
|
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
|
||||||
|
CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — and native UDT member writes (DINT/BIT/REAL, #611 gate, same day) and a native STRING write (hw5, same day, restored byte-exact); and a native array-element write (`EM01_PowerUnit._SEQ_EP[31]`, user-approved, an element no alias targets, restored; neighbours unchanged); libplctag member and fragmented writes are `ab_server`-only. #566: a real Logix drops TCP on a connection idled past its timeout (no 0x0107/0x0203); the faulted session reopens on the next request.
|
||||||
|
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
|
||||||
|
a real MicroLogix 1400 (Z-33323, idle, 2026-10-09) native read S/N/F/B/T/C/R/ST/O/I incl. `S:0`, T/C/R
|
||||||
|
sub-elements and status bits, and ST byte order, all correct, and wrote N/F8/B-bit (restored). libplctag (the default)
|
||||||
|
used to fail `S:`/`O:`/`I:` and `R` status bits there (incl. the default probe `S:0`) — its `pccc.c` needs explicit
|
||||||
|
file numbers and has no R status-bit mnemonics; since #659 `LibplctagLegacyName` translates (`S2:`/`O0:`/`I1:`,
|
||||||
|
`R6:0.CON/13`) and both transports read identically on that press. The authored grammar is unchanged. Read-side
|
||||||
|
only: a libplctag WRITE to any address the translation changes (`S:`/`O:`/`I:` incl. bit writes, R `EN…FD`, `C…UA`)
|
||||||
|
is refused `BadNotSupported` before any runtime is created (none could reach the PLC before — no write-surface
|
||||||
|
widening on the default transport); libplctag T/C status-bit writes it parses itself are unchanged.
|
||||||
|
L/A files, function files, SLC, PLC-5 and `logixpccc` remain unverified natively. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning.
|
||||||
|
AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`).
|
||||||
|
|
||||||
|
Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type
|
||||||
|
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
|
||||||
|
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
|
||||||
|
once per tag — only genuine transport failures evict and degrade (a runtime `NotSupportedException` too, #611 —
|
||||||
|
it used to latch `Degraded`). AbCip width-checks every scalar **write** against the controller's bytes before
|
||||||
|
sending, on both transports (#611; libplctag used to patch part of its buffer and send it all). Controller-discovered
|
||||||
|
UDT members are writable once browse-committed and authored `ReadWrite` (BIT members included — the controller
|
||||||
|
applies the bit) — native member writes incl. a BIT member are live-gated on Z35162 (2026-10-09); sibling-bit preservation and the libplctag path are ab_server-only; whole-UDT writes are refused `BadNotSupported` by decision (`docs/drivers/AbCip.md` §Member writes). `DeviceSessionPool.Shared` logs through the
|
||||||
|
host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT factories' `Register(registry, ILoggerFactory?)` take one too, #602 CONV-3). The AbCip `browsePageDelayMs` knob has an AdminUI field (`AbCipDriverForm` "Browse page delay (ms)"). libplctag is confined to `Libplctag*.cs` adapters by
|
||||||
|
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
|
||||||
|
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
|
||||||
|
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
|
||||||
|
|
||||||
|
## Other driver notes
|
||||||
|
|
||||||
|
- **S7:** connect attempts back off 1 s doubling to 30 s (one attempt per window, not one per call/poll/probe
|
||||||
|
tick); a write-side access-denied (PUT/GET disabled) latches a **sticky Faulted** that outranks later read
|
||||||
|
passes; driver-side array writes cover non-Bool narrow/`Int32`/`Float32` types only — Bool arrays are
|
||||||
|
refused (a one-byte-per-element encode would clobber neighbour bits).
|
||||||
|
- **MQTT:** Plain `writeTopic` writes publish at QoS 1 (Sparkplug NCMD/DCMD stay QoS 0); the Sparkplug
|
||||||
|
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
|
||||||
|
`BadNotSupported`).
|
||||||
|
- **No driver-side history (#655):** `IHistoryProvider`, `DriverCapability.HistoryRead`, `HistoryReadStatusException` and
|
||||||
|
the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the
|
||||||
|
server-wide `IHistorianDataSource`. `isHistorized` on an OpcUaClient tag means OtOpcUa records it, not that upstream
|
||||||
|
history is proxied. A stored `ResilienceConfig` still carrying a `HistoryRead` key parses (diagnostic only).
|
||||||
|
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
|
||||||
|
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
|
||||||
|
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
|
||||||
|
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
|
||||||
|
- **FOCAS alarms (#647):** an `ALARM:` address (`*`, `<Type>`, `<Type>/<n>`, `<Type>/<lo>-<hi>`, `T<code>`, `OTHER`) +
|
||||||
|
the `alarm` object = ONE condition at the RawPath (either alone is skipped + reported). Overlapping selectors all
|
||||||
|
fire; `OTHER` = what no type selector claims. ⚠️ A failed `cnc_rdalmmsg2` THROWS (it used to return `[]` ⇒ mass
|
||||||
|
clear + un-acked re-raise) — and annotates its conditions `BadNoCommunication` once per outage (quality-only) and
|
||||||
|
degrades driver health with `LastSuccessfulRead` frozen, so an alarm-only driver trips the #527 watchdog. ⚠️ A CNC
|
||||||
|
REFUSAL (non-OK rc, transport fine) is `BadNotSupported` + one Warning and does NOT touch health — else the
|
||||||
|
watchdog reconnect-loops a production control. Health = worst of the value and alarm paths, computed together. The told
|
||||||
|
record lives on the driver INSTANCE (reconnect / in-place re-init re-raise nothing); a deploy RESPAWNS the driver,
|
||||||
|
and its Raise for a still-active alarm is a host-side re-statement (below). Type names are the 16i-era table only
|
||||||
|
(30i codes unconfirmed, not invented — use `T<code>` there). Read-only; ack server-side. `AlarmProjection.Enabled`
|
||||||
|
is a no-op. E2E: `Runtime.Tests/FocasNativeAlarmEndToEndTests` (incl. a respawn). `docs/drivers/FOCAS.md`.
|
||||||
|
- **A Raise on an already-active condition with the SAME message is a RE-STATEMENT (#647 review):** `NativeAlarmProjector` keeps its ack and
|
||||||
|
`ForwardNativeAlarm` writes no second "Activated" `/alerts` row — every native driver's told-record dies with its
|
||||||
|
instance, and a deploy respawns the driver. Only `Retrigger` (or inactive → active) un-acks, so a source whose
|
||||||
|
UPSTREAM is authoritative for the ack sends Retrigger for an unacked re-statement: OpcUaClient on first sight of a
|
||||||
|
condition (no prior told), Galaxy for an unacked active snapshot entry. **Those two also flag
|
||||||
|
`AlarmEventArgs.IsRestatement` (#656)** — history-only: a flagged Retrigger on an Active+unacked condition with the
|
||||||
|
same message AND severity, or a flagged Clear on an inactive one, writes no `/alerts` row (every Galaxy reconnect used
|
||||||
|
to duplicate them); a flagged `Unspecified` on an already-active condition (AbCip ALMA governing-limit drop-out)
|
||||||
|
moves the node's text/severity, keeps active/acked and writes no row. Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot
|
||||||
|
`ActiveAcked`, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host's `Active=false`: the alarm was
|
||||||
|
invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirror **`InactiveUnacknowledged`** (#657, OpcUaClient)
|
||||||
|
replaces Raise/Retrigger+Clear (an Active flicker; rows only for what changed). OpcUaClient's first sight of an inactive
|
||||||
|
condition is a flagged InactiveUnacknowledged, or flagged Clear+Acknowledge for inactive+acked (a flagged Acknowledge on an
|
||||||
|
acked condition writes no row) — never `Unspecified`, which wrote "Activated" for an inactive alarm and left a
|
||||||
|
retained-Active condition Active. `NativeAlarmProjector` returns the rows to write; unflagged
|
||||||
|
sources (FOCAS/MTConnect, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
|
||||||
|
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||||
|
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection;
|
||||||
|
FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||||
|
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
|
||||||
|
"tag name collision"); S7 swaps `Plc` under `_gate`.
|
||||||
|
- **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every
|
||||||
|
deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook
|
||||||
|
`OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile
|
||||||
|
re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick.
|
||||||
|
- **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied`
|
||||||
|
seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect
|
||||||
|
`AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync`
|
||||||
|
stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is
|
||||||
|
handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630).
|
||||||
|
`docs/DriverLifecycle.md`.
|
||||||
|
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
||||||
|
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
|
||||||
|
Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test
|
||||||
|
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged, on
|
||||||
|
`S7Driver.GetDiagnostics()`, and (#640) on `/hosts` via the optional `IDriverDiagnostics` bag (string map on
|
||||||
|
`DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint).
|
||||||
|
- **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter
|
||||||
|
(and keeps historizing — DateTime is not historized).
|
||||||
|
- **TwinCAT (#645, #310/#314):** opt-in ADS sum-read (`useSumRead`, `sumReadMaxItems` default 200 / ceiling 500, 32 KiB payload
|
||||||
|
budget). It reads raw bytes and decodes as the declared type, so only a scalar passing the strict width+kind check
|
||||||
|
(`CheckNotification`) rides a batch; value-converted pairs (DInt over INT, Real over LREAL/DINT) read singly. Only overall
|
||||||
|
ADS 1793/1794 latch the device to single reads; any other non-comms overall code (1792/1795/1797/1798...) reads that chunk singly for the poll without latching, with a once-per-device Warning; a comms failure neither.
|
||||||
|
Opt-in `diagnosticsEnabled` (default off) puts ADS state/app/online-change/cycle/exec/`HeadroomMs` on the `/hosts`
|
||||||
|
`IDriverDiagnostics` bag (≤ 4 extra reads per device per probe tick once settled; TC2 falls back to `.SYSTEMINFO` /
|
||||||
|
`.SYSTEMTASKINFOARR[1]`, costing 8 on the first tick and after each reconnect). Live-gated read-only 2026-10-08 on
|
||||||
|
Z35474 (TC3) + Z34834 (TC2, which answers sum commands) via a harness driving `TwinCATDriver` — `/hosts` end-to-end
|
||||||
|
not run (a stock host has no AMS router); the gate found ⚠️ Beckhoff's `SumRead` ctor throws for a `List<>` of entities (only an array survives
|
||||||
|
its internal cast) — `AdsTwinCATClient.CreateSumRead` owns it. Flat browse stays (VirtualTree is a strict subset
|
||||||
|
on TC3). Sum-write not ported.
|
||||||
|
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
|
||||||
|
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files parse
|
||||||
|
but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read
|
||||||
|
every call, never cached; bad index = `BadOutOfRange`; indirect writes are not atomic). One check (`AbLegacyAddress.Validate`) backs the
|
||||||
|
editor (family-independent) and the deploy gate (family from the tag's `DeviceConfig.PlcFamily`); an unparseable
|
||||||
|
address reads `BadNodeIdUnknown` for that tag only.
|
||||||
|
- **OpcUaClient (#635):** OperationLimits are honoured by the SDK session (`SessionClientBatched`) — do not re-add
|
||||||
|
driver-side chunking. Subscription tuning keys are range-checked at `ParseOptions`. `discoveryUrl` endpoints go
|
||||||
|
AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session
|
||||||
|
events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13
|
||||||
|
set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node.
|
||||||
|
- **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing
|
||||||
|
with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device
|
||||||
|
(`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second).
|
||||||
|
- **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig`
|
||||||
|
carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break
|
||||||
|
existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe
|
||||||
|
active nativeCode (ties: ordinal nativeCode, then type); ack is server-side only. ⚠️ UNAVAILABLE never clears: an
|
||||||
|
active condition stays active+acked with a quality-only `BadNoCommunication` (same as #481 transport loss). The
|
||||||
|
driver dedups against what the server was last TOLD (level+nativeCode+nativeSeverity+qualifier — not text), records
|
||||||
|
only while a handler is attached, and drops the record of only the conditions that LEFT the set when the subscribed
|
||||||
|
alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack).
|
||||||
|
`AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only
|
||||||
|
event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection.
|
||||||
|
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
|
||||||
|
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
|
||||||
|
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
|
||||||
|
discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact
|
||||||
|
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS routes since #647 (below).
|
||||||
|
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
||||||
|
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
|
||||||
|
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||||
|
emits Health) on the thread pool *after* `StopChild`, which used to resurrect the state — so a removed
|
||||||
|
driver's counters were republished forever. `Release` → tracker `Retire` and hub `Evict` ignore every later
|
||||||
|
write for that id until it is spawned again (`Create` → `Reinstate`, `SpawnChild` → `Readmit`). A
|
||||||
|
Watch/`Terminated` release would not work — the late writes land after the actor terminates.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.*/**"
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/**"
|
||||||
|
- "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/**"
|
||||||
|
- "src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**"
|
||||||
|
- "tests/**/*Historian*/**"
|
||||||
|
- "docs/Historian.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Historian / HistoryRead (Gateway, SqlServer, QuestDb backends; alarm history; continuous historization; provisioning)
|
||||||
|
|
||||||
|
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
|
||||||
|
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
|
||||||
|
|
||||||
|
## Historian / HistoryRead
|
||||||
|
|
||||||
|
**Backend: pluggable — `ServerHistorian:Backend` = `Gateway` (default), `SqlServer` or `QuestDb`** (see the subsection below).
|
||||||
|
As of the gateway-integration cutover, the **Gateway** backend's historian read, alarm-write, and
|
||||||
|
continuous-historization paths are all served by the **`ZB.MOM.WW.HistorianGateway`** sidecar, consumed as the Gitea-feed
|
||||||
|
**`ZB.MOM.WW.HistorianGateway.Client`** gRPC package (`historian_gateway.v1`) behind a thin
|
||||||
|
`IHistorianGatewayClient` seam in `ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway`. **The bespoke
|
||||||
|
Wonderware TCP/ArchestrA sidecar projects and the vestigial `Historian.Wonderware` driver type were
|
||||||
|
retired** — there is no Wonderware backend in the tree anymore (see `docs/drivers/Historian.Wonderware.md`,
|
||||||
|
now a retired stub).
|
||||||
|
|
||||||
|
A tag is historized by adding `"isHistorized": true` to its `TagConfig` JSON blob (authored in the
|
||||||
|
raw-JSON textarea on the `/uns` TagModal); an optional `"historianTagname"` field overrides the default
|
||||||
|
historian tagname, which is the tag's driver `FullName`.
|
||||||
|
|
||||||
|
### Pluggable backends, SQL Server and HistoryUpdate (2026-10)
|
||||||
|
|
||||||
|
- **Selection:** `ServerHistorian:Backend` (`Gateway` default | `SqlServer` | `QuestDb`; unknown fails host start). `HistorianBackendRegistry` → `IHistorianBackendProvider` builds all five seams; the gateway keys are only required for `Gateway`. `SqlServer` uses `ServerHistorian:SqlServer:{ConnectionString (required), Schema=hist, CommandTimeout=30s, AutoCreateSchema=true}`.
|
||||||
|
- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention.
|
||||||
|
- **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend".
|
||||||
|
- ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates.
|
||||||
|
- ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `docs/Historian.md` §"Open-ended and reverse raw reads".
|
||||||
|
- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates.
|
||||||
|
- **Raw `ReturnBounds` is honoured on every backend (#691, client-visible):** Part 11 bounding values (§4.6 Table 1 is the test oracle) are composed in the server by `HistoryBounds` from each backend's raw reads — "at or before" = capped reverse read (native on SQL backends, the #686 `ReverseRawReader` walk-back on Gateway, never AVEVA's synthetic boundary row), "at or after" = capped forward read. Bounds are stored samples of any quality, count toward `NumValuesPerNode`, start bound on the first page / end bound on the last (pending end bound rides the continuation point); missing ⇒ `Bad_BoundNotFound` (null, at the bound time; a one-sided read's open side ±1 s from the previous value). ⚠️ **Breaking for SDK-default clients:** the .NET SDK's `new ReadRawModifiedDetails()` defaults BOTH `ReturnBounds = true` AND `IsReadModified = true` — a client that cleared only `IsReadModified` now gets bounds, and one that set neither is a modified read with bounds ⇒ `Bad_InvalidArgument` (§6.5.3.3); set both explicitly (Client.Shared does; tests building details must too). No data AND no bound found ⇒ `GoodNoData`, no values (the pre-bounds answer). On Gateway the at-or-after lookup ends at now + 1 day (never a year-9999 end to AVEVA). Kill switch `ServerHistorian:HonorReturnBounds` (default `true`; `false` = pre-#691). `docs/Historian.md` §Bounding values.
|
||||||
|
- ⚠️ **Raw pages are full (#692, client-visible, every backend):** each raw page asks the backend for `N + skip + 1` rows (the boundary ties it will trim + one lookahead row) — a resumed page used to ask only N, trim the ties and come back short; at N=1 that was an EMPTY page + continuation point that then fell into the `MaxTieClusterOverfetch + 1` over-fetch (live: 8 values = 17 pages / 25 reads / 524k rows ⇒ now 8/8/≤24). No lookahead row ⇒ last page, no CP (an exact multiple no longer ends on an empty page). Exception: an oversized tie cluster — its last slice is `min(N, remaining)` with a CP, and stepping past one at the end of data can give one empty final page. The over-fetch fires only when every row past the trimmed ties sits on the boundary timestamp; a cursor inside such a cluster is marked `WithinTieCluster` (next page goes straight to the over-fetch). `SliceTieCluster` resumes only strictly before EndTime (truncated to the backend resolution) — it used to resume AT the exclusive EndTime and return samples there. Bound placeholders carry an explicit marker (`HistoryBounds.IsPlaceholder` no longer matches a stored `Bad_BoundNotFound`/null row); the N=1 bounds probe carries its walk hint + resolved end bound into the cursor. Reviewer fuzz harnesses (paged vs unpaged over random tie series) are the right test for any paging change — repo tests passed while master mismatched 1-in-160.
|
||||||
|
- ⚠️ **Strict-ceiling paging (#694):** `IHistorianDataSource.MaxRawValuesPerRead` = L = learned gateway ceiling − 16 (0 on SQL backends/Null, guard-tested; 0 until a refusal names the ceiling). `RawPageLimit.Plan(want, k, L)` sizes every raw page so page + boundary ties + lookahead ≤ L (room = L − k − 1, signed; first page L−1, resumed L−k−1; uncapped `N = 0` reads page too) — a capped read above the ceiling used to be `BadTooManyOperations`, now a ceiling-sized page + CP. One re-plan when the read taught a new limit or was refused for crowding (`HistoryReadLimitExceededException.PreWindowRows`, set only above the 16-row allowance; the page shrinks by it). No room on a resumed page ⇒ the tie-cluster over-fetch, bounded min(M, L−1); the slice size is never 0. The page path passes `retryOnLearnedLimit: false` to `ReverseRawReader` (the walk re-running at the old N cost 200–3,000 reads cold); bounds lookups keep it. ⚠️ A Gateway window starting at/before the Unix epoch counts AVEVA's synthetic row as pre-window — it used to set `reachedEnd`, so a ceiling-truncated read came back Good with no CP (forward-from-1970 = first page only; end-only past the data = the OLDEST rows as "latest"; pre-existing). The #687 crowding re-read now doubles its room up to 8 re-reads, never past a known ceiling, and its room is capped at `MaxTieClusterOverfetch` (passed to the Gateway source by the factory; past it ⇒ `BadTooManyOperations` with `PreWindowRows`). A raised ceiling is not re-probed by raw paging (pages stay small until another read re-probes or restart).
|
||||||
|
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
|
||||||
|
- **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node.
|
||||||
|
- **HistoryUpdate** (SqlServer + QuestDb; Gateway ⇒ `BadHistoryOperationUnsupported`): Insert/Replace/Update/DeleteRaw/DeleteAtTime, **Primary-only** since #674 (off-Primary ⇒ `BadInvalidState`, after authorization AND after planning — an unservable request (Remove / `IsDeleteModified` / not historized) stays `BadHistoryOperationUnsupported` off-Primary; the verdict is `IHistoryUpdateRoleView` = the data-plane verdict but **closed until this node's first redundancy snapshot**, so a pair cannot both accept in the boot window; admin-only nodes register no view ⇒ no gate; reads ungated), items with a `SourceTimestamp` past now + `ServerHistorian:HistoryUpdateMaxFutureSkew` (default 1 day, ≤0 disables) ⇒ `BadOutOfRange` in the node manager (every backend), gated in both ACL modes — flag off needs the `HistoryUpdate` role from a **Cluster-scoped** grant (+ key re-check), flag on uses `DataPlaneAuthorizer`'s HistoryUpdate operation; `WriteOperate` never implies it. The `HistoryWrite` AccessLevel bit is set only when an updater is wired at materialise time. `docs/Historian.md` §HistoryUpdate, `docs/security.md`. ⚠️ **SqlServer schema v1 is accepted (#674)** with `AutoCreateSchema=false`: writes/reads/events/retention work; modified reads + HistoryUpdate ⇒ node-level `BadHistoryOperationUnsupported` on every arm (the updater throws `NotSupportedException`, nothing sent; the `HistoryWrite` bit is still advertised — not knowable at materialise time), retention skips `SampleModified`, one Warning per boot (`SqlServerSchemaBootstrapper.SupportsModifiedTrail`; `RequiredVersion` = 1, `ModifiedTrailVersion` = 2). QuestDB: keep `cairo.ttl.use.wall.clock=true`; a QuestDB trail INSERT retried after a client timeout duplicates (no DEDUP key).
|
||||||
|
- **Modified-value history (#668, Part 11):** every applied HistoryUpdate item leaves a trail row (prior value, principal from the session's API key / `anonymous`, `HistoryModificationType`, time) — SqlServer `hist.SampleModified` (**schema v2**, written by `OUTPUT … INTO` in the same statement ⇒ atomic; ⚠️ no FK/CHECK/trigger on it, and `AutoCreateSchema=false` installs must run `002_sample_modified.sql` before upgrading), QuestDB `{p}sample_modified` (append-only, written **before** the change ⇒ a crash leaves an orphan trail row, never an untracked change). `IsReadModified=true` HistoryRead serves it as `HistoryModifiedData` via `IHistorianDataSource.ReadModifiedAsync` (default ⇒ `NotSupported` ⇒ `BadHistoryOperationUnsupported` on Gateway/Null), paged on `(SourceTime, ModifiedId)`, gated by plain HistoryRead. Live writes and retention never write it; retention/TTL purge it on source time. `IHistorianDataUpdater` methods take a `HistoryUpdateContext`. CLI `historyread --modified`. `docs/Historian.md` §Modified-value history.
|
||||||
|
|
||||||
|
### Read path (`ServerHistorian` section)
|
||||||
|
|
||||||
|
The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the
|
||||||
|
`GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default
|
||||||
|
(historized nodes return `GoodNoData` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw (⚠️ #687, client-visible: Gateway raw reads no longer return AVEVA's synthetic start-boundary row — asked from 1 ms early (epoch-floored) with `NumValuesPerNode + 16` rows (unclamped; 0 ⇒ `int.MaxValue`) and filtered by timestamp — so an empty window is `GoodNoData`, not a fabricated value at StartTime; the forward end is now exclusive (a point read inclusive); pre-window rows that crowd the budget trigger ONE re-read with room for them, else `BadTooManyOperations`; `ReturnBounds` is ignored on every backend; ⚠️ #690: a Gateway Bad row — a stored NULL, which histsdk sends as value 0 — now reads value **null** + Bad on Raw and AtTime, as on the SQL backends; ⚠️ #691.3: a strict gateway (`AllowStreamingBeyondCeiling=false`) that refuses a raw budget is re-asked ONCE at the ceiling its refusal names — raw and per-sample reads alike; the ceiling is then remembered per data source (`GatewayRowCeiling`: refusals count as gateway errors), re-learned from a refusal when lowered, and re-probed once at the full budget when a ceiling-bounded read comes back full (raised / streaming) — so an uncapped (`NumValuesPerNode = 0`) read of a small window is served; a ceiling-bounded stream that is not provably complete is `BadTooManyOperations`, never a truncated answer), Processed
|
||||||
|
(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27; ⚠️ #691.2: native reads use the SQL backends' grid in both directions via forward requests only — a REVERSE read (refused by the gateway before) is anchored at its later time (#677), and the clipped interval (earliest reverse, LATEST forward — which used to be averaged past EndTime) is its own exact-span read marked Partial unless Bad; an interval wider than the window is sent as the window, interval 0 as one interval; more than 100,000 intervals is refused up front (`BadHistoryOperationUnsupported`, as on the SQL backends — forward used to be served up to the gateway's 1,000,000); until historiangw#27 lands both directions return Good-status wrong values); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes (⚠️ #693, client-visible: Gateway AtTime is evaluated from RAW reads by the shared `Sql.Common` `AtTimeEvaluator` — exact stored value at a stored time, null + Bad at/after a stored NULL, Uncertain hold before a Bad neighbour, linear + Interpolated bit, `BadNoData` before the first sample — never AVEVA's own interpolation (which answered 107 Good at a NULL, null+Bad at a Good sample after it, 99.99999999944 for 100, no Interpolated bit); `GatewayAtTimeReader`: one ≤1,024-row window read for the batch (jumps to far-apart times use 64-row reads) + a walk-back for the first time's prev (gallop ×4, whole-remainder read after two empties, then real BISECTION seeded by any full read's last row — a dense tag stopped a year ago ≈ 25–31 reads; the first cut regrew segments after a full read and gave up at 65) + an 18-row probe for the last time's next — 10 dense times = 1 read (exact) / 3 (interpolated), 10 hourly = 12; read cap 64 + 16 per distinct time (a runaway guard: a time in a gap of a bursty tag needs ≈ 6 reads; 4/time refused a 1 h/day tag asked daily ×30) and a strict ceiling < 4 rows ⇒ `BadTooManyOperations` with a message naming the limit; DateTime hints stepped, non-finite/out-of-range values under an integer hint unusable; all request bounds whole ms, rows counted only strictly inside a request; the node manager passes the node's scalar type (`ReadAtTimeAsync(…, TypeCode? valueType, …)` default-interface overload, ignored by SqlServer/QuestDB/Null) and the Gateway converts raw rows to it before evaluating — Boolean stepped, integers interpolated + rounded half-away and returned as the node's integer type, floats Double; no hint ⇒ Double; ⚠️ the shared evaluator used to box interpolated Int64 as Double (`cond ? (long)x : double` widens) — fixed for SQL backends too, and Shouldly `ShouldBe` hides numeric-type bugs (assert `ShouldBeOfType`); kill switch `ServerHistorian:GatewayAtTimeFromRaw` (default `true`); `docs/Historian.md` §Gateway AtTime); Events over
|
||||||
|
alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node);
|
||||||
|
authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization.
|
||||||
|
|
||||||
|
`ServerHistorian` appsettings keys (`ServerHistorianOptions`; `Enabled` defaults to `false`):
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Enabled` | `false` | `true` registers the gateway read client; `false` keeps `NullHistorianDataSource` |
|
||||||
|
| `Endpoint` | `""` | Absolute gateway URI, e.g. `https://host:5222`. Scheme selects transport (`https://` = TLS, `http://` = h2c) |
|
||||||
|
| `ApiKey` | `""` | Peppered-HMAC key `histgw_<id>_<secret>` sent as `Authorization: Bearer`. **Supply via env `ServerHistorian__ApiKey` — never commit** |
|
||||||
|
| `UseTls` | `true` | Connect over TLS; must match the `Endpoint` scheme |
|
||||||
|
| `AllowUntrustedServerCertificate` | `false` | Accept a self-signed / untrusted server cert (dev / on-prem only) |
|
||||||
|
| `CaCertificatePath` | `null` | PEM CA file pinning the gateway TLS chain; null/empty uses the OS trust store |
|
||||||
|
| `CallTimeout` | `00:00:30` | Per-call deadline for each unary gateway read |
|
||||||
|
| `MaxTieClusterOverfetch` | `65536` | Bounded over-fetch the HistoryRead-Raw paging uses to page within an oversized same-timestamp tie cluster (retained from the prior backend) |
|
||||||
|
| `HonorReturnBounds` | `true` | #691 kill switch: `false` ignores HistoryRead-Raw `ReturnBounds` as before (no bounding values, no modified-read `Bad_InvalidArgument`) |
|
||||||
|
| `GatewayAtTimeFromRaw` | `true` | #693 kill switch (Gateway only): `false` = AVEVA's own AtTime interpolation passed through, as before |
|
||||||
|
| `EventReadHorizon` | `00:00:00` | #694, opt-in and LOSSY: when > 0, HistoryReadEvents never reads events older than now − horizon (fresh windows clamped, both directions, all backends; a window wholly older ⇒ `GoodNoData` unsent). Bounds the deep "rest is empty" event probe (~3.8 s live, 30 s gateway SQL timeout unfiltered). No per-source earliest-event cache by design — late S&F drains would make it stale |
|
||||||
|
| `HistoryReadBatchParallelism` | `4` | Max nodes served concurrently within one HistoryRead batch (03/S3); `⌈N/P⌉ × CallTimeout` worst case instead of sequential |
|
||||||
|
| `MaxConcurrentHistoryReadBatches` | `16` | Process-wide concurrent HistoryRead batch cap (03/S3); flood degrades to `BadTooManyOperations`. Total in-flight reads capped at 16 × 4 = 64 |
|
||||||
|
| `MaxTimeWeightedRawRows` | `1000000` | `SqlServer`/`QuestDb` (#671): raw in-window rows one time-weighted aggregate read (incl. `Total` and, since #680, PercentGood/PercentBad) may stream into the C# evaluator — and, since #681, every `Gateway` per-sample read (raw-evaluated); window query capped at this + 1, past it ⇒ `BadTooManyOperations` for that node. ≤ 0 = unbounded. Gateway (#688): the `cap + 18` budget is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is ≤ it, so the default no longer trips a strict (`AllowStreamingBeyondCeiling=false`) gateway |
|
||||||
|
| `HistoryReadDeadline` | `00:01:00` | Per-request HistoryRead wall-clock deadline (03/S3); a node still in flight surfaces `BadTimeout`. Non-positive = unbounded |
|
||||||
|
|
||||||
|
### Alarm-history path (`AlarmHistorian` section)
|
||||||
|
|
||||||
|
Alarm events are written through `GatewayAlarmHistorianWriter` (the gateway **`SendEvent`** path) behind
|
||||||
|
the durable **`LocalDbStoreAndForwardSink`** — `AlarmHistorian:Enabled=true` swaps the `NullAlarmHistorianSink`
|
||||||
|
default for the store-and-forward queue, which buffers into the node's consolidated LocalDb as the
|
||||||
|
replicated `alarm_sf_events` table (see the LocalDb section) and whose drain worker forwards batches to
|
||||||
|
the gateway and uses per-event outcomes to decide retry vs. dead-letter (never throws). **Only the node
|
||||||
|
holding the Primary role drains** (a gated node reports `HistorianDrainState.NotPrimary`). The
|
||||||
|
`AlarmHistorian` section carries only the `Enabled` gate + the queue knobs (`DrainIntervalSeconds`,
|
||||||
|
`Capacity`, `DeadLetterRetentionDays`, `BatchSize`, `MaxAttempts`) — the downstream gateway connection
|
||||||
|
(endpoint/key/TLS) is sourced from the `ServerHistorian` section. **Alarm-history `ReadEvents` requires the
|
||||||
|
target gateway deployed with `RuntimeDb:EventReadsEnabled=true`** (the C2 SQL event-read workaround).
|
||||||
|
|
||||||
|
### Continuous historization (`ContinuousHistorization` section)
|
||||||
|
|
||||||
|
When `ContinuousHistorization:Enabled=true` **and** `ServerHistorian` is configured, the Host builds a
|
||||||
|
durable, crash-safe **FasterLog** outbox (`FasterLogHistorizationOutbox`) + a gateway-backed
|
||||||
|
`IHistorianValueWriter`, and `WithOtOpcUaRuntimeActors` spawns the `ContinuousHistorizationRecorder`. The
|
||||||
|
recorder taps the per-node dependency-mux value fan-out, appends each numeric value to the outbox (the
|
||||||
|
crash boundary), and drains the outbox to the gateway's SQL live-value write path (**`WriteLiveValues`**)
|
||||||
|
with exponential backoff. The gateway connection is sourced from `ServerHistorian`; this section carries
|
||||||
|
only the recorder + outbox knobs:
|
||||||
|
|
||||||
|
| Key | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Enabled` | `false` | `true` (with `ServerHistorian` configured) wires + spawns the recorder |
|
||||||
|
| `OutboxPath` | `""` (required when enabled) | **Directory** holding the FasterLog segment + commit files. In production set an **absolute** path on durable storage |
|
||||||
|
| `CommitMode` | `PerEntry` | `PerEntry` = fsync before the append returns (no loss window); `Periodic` = batched commits every `CommitIntervalMs`. ⚠️ `PerEntry` now means **one fsync per ingest burst, not per value** — the recorder appends a burst through `AppendBatchAsync`, which enqueues every entry then issues a single group commit; the call still does not return until that commit lands |
|
||||||
|
| `CommitIntervalMs` | `100` | Periodic-mode commit cadence; required `> 0` only under `Periodic` |
|
||||||
|
| `DrainBatchSize` | `1024` | Entries peeked + written per drain pass. **Raised from 64**: the peek is an in-memory scan and the pass is bounded-parallel (not sequential), so a wide batch no longer costs `tags × latency` — while 64 drew down ≤ 64 entries per `DrainIntervalSeconds`, letting the outbox fall permanently behind during any gateway hiccup |
|
||||||
|
| `DrainIntervalSeconds` | `2` | Steady drain cadence (and post-success reschedule) |
|
||||||
|
| `DrainConcurrency` | `8` | Historian tags one drain pass writes **concurrently** (a batch is grouped per tag; each group is its own gateway round-trip), so a sequential `tags × latency` pass becomes `⌈tags / concurrency⌉ × latency` with outstanding round-trips bounded. `1` = fully sequential. The ack is unchanged — all-or-nothing after every group reports, and the #538 quarantine still keys on "another group wrote this pass" |
|
||||||
|
| `Capacity` | `500000` | Max un-acked outbox entries before drop-oldest; `0` = unbounded. Non-zero **by default since #538** — the disk backstop for a historian refusing *every* write, the one case the per-tag quarantine deliberately cannot resolve |
|
||||||
|
| `MaxWriteAttempts` | `5` | #538: consecutive failed drain passes one tag may accumulate — counted **only across passes where other tags wrote successfully** — before its queued entries are abandoned so the FIFO head can advance |
|
||||||
|
| `MinBackoffSeconds` | `1` | Initial retry backoff after a failed drain pass |
|
||||||
|
| `MaxBackoffSeconds` | `30` | Cap on the exponential retry backoff |
|
||||||
|
| `CaptureWhilePrimaryUnreachable` | `true` | #672: a standby stood down for its mesh Primary (#537) captures as soon as that Primary is reported UNREACHABLE, instead of after auto-down — crash-failover gap ~30 s ⇒ ~10–12 s, at the cost of duplicate windows (see #537/#672 below). `false` = pre-#672 |
|
||||||
|
|
||||||
|
### Tag auto-provisioning (`IHistorianProvisioning` EnsureTags hook)
|
||||||
|
|
||||||
|
`AddressSpaceApplier.Apply()` fires a **non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync`
|
||||||
|
hook for added historized value tags — and, since #600.1, for **changed** raw tags that flip to historized or change their resolved historian name or DataType — the gateway-backed `GatewayTagProvisioner` calls the gateway's
|
||||||
|
`EnsureTags` so a brand-new historized tag exists in the historian before the recorder's `WriteLiveValues`
|
||||||
|
land. The hook is wrapped so a faulted/synchronously-throwing provisioner can **never** block or fail a
|
||||||
|
deploy. **Gateway backend:** non-numeric (`String`/`DateTime`/`Reference`) data types are skipped (not
|
||||||
|
provisioned), the recorder likewise drops + meters non-numeric values, and continuous historization is
|
||||||
|
**numeric-analog only** (`UInt16→UInt4` is a documented fallback). The **SqlServer** backend provisions and
|
||||||
|
records every type (see "Pluggable backends" above).
|
||||||
|
|
||||||
|
The provisioner is registered by the Host's `AddHistorianProvisioning` (`Runtime.ServiceCollectionExtensions`),
|
||||||
|
built via `GatewayHistorian.CreateProvisioner`, and **gated on the same `ServerHistorian:Enabled` flag as the
|
||||||
|
read path** — `WithOtOpcUaRuntimeActors` resolves `IHistorianProvisioning` and passes it into the applier.
|
||||||
|
When the section is disabled the applier binds the no-op `NullHistorianProvisioning` and provisioning does not
|
||||||
|
run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with
|
||||||
|
`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR
|
||||||
|
#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.)
|
||||||
|
**DataType mapping (issue #441):** `EnsureTags` is an **upsert** (create-or-update, not skip-if-exists).
|
||||||
|
`HistorianTypeMapper` maps **`Boolean → Int2`** (Boolean historizes as a small 0/1 integer) because the
|
||||||
|
historian's `Int1` analog-tag creation path is **server-degenerate** — `EnsureTags(Int1)` stores an unusable
|
||||||
|
stub (live-probed; gateway-side resolution in HistorianGateway #11 / PR #16). Both Float and Int2 are
|
||||||
|
supported analog types, so a pre-existing Float Boolean tag retypes cleanly to Int2 on its next deploy — see
|
||||||
|
the "Boolean historization maps to Int2" section in `docs/Historian.md`.
|
||||||
|
|
||||||
|
### Gateway-side prerequisites
|
||||||
|
|
||||||
|
The target HistorianGateway OtOpcUa points at **must** run with:
|
||||||
|
|
||||||
|
- `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization).
|
||||||
|
- `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads).
|
||||||
|
- `RuntimeDb:EventReadMaxRows=0` (its default, unlimited) — or larger than the reverse-events walk's biggest read
|
||||||
|
(`2·MaxTieClusterOverfetch + 4`). The cap truncates **silently**, so a walked-back reverse events read (#691.4) would
|
||||||
|
lose the newest events of a segment it cut.
|
||||||
|
- An API key carrying scopes **`historian:read`**, **`historian:write`**, **`historian:tags:write`**.
|
||||||
|
|
||||||
|
### Migration note (deployments upgrading from the Wonderware backend)
|
||||||
|
|
||||||
|
The `ServerHistorian` section changed shape. Rename the old Wonderware keys and supply the key via env:
|
||||||
|
|
||||||
|
| Old (Wonderware) key | New (gateway) key |
|
||||||
|
|---|---|
|
||||||
|
| `ServerHistorian:Host` + `:Port` | `ServerHistorian:Endpoint` (`https://host:5222`) |
|
||||||
|
| `ServerHistorian:SharedSecret` | `ServerHistorian:ApiKey` (**env `ServerHistorian__ApiKey`**) |
|
||||||
|
| `ServerHistorian:ServerCertThumbprint` | `ServerHistorian:CaCertificatePath` (+ `UseTls` / `AllowUntrustedServerCertificate`) |
|
||||||
|
|
||||||
|
The `AlarmHistorian` section's old Wonderware connection keys (`Host`/`Port`/`UseTls`/`ServerCertThumbprint`/`SharedSecret`)
|
||||||
|
were pruned — remove them; the SQLite knobs are retained and the downstream connection now comes from
|
||||||
|
`ServerHistorian`. See `docs/Historian.md` for the full guide.
|
||||||
|
|
||||||
|
### Live-validation gate — PASSED (was "KNOWN LIMITATION 1")
|
||||||
|
|
||||||
|
**No longer a limitation.** The cutover was live-validated against the real gateway on
|
||||||
|
`wonder-sql-vd03`: read ✅, write-persist ✅, alarm-send ✅, alarm-readback skipped as a *confirmed
|
||||||
|
protocol limitation* (the captured `CM_EVENT` wire never carries `SourceName`, so the gateway cannot
|
||||||
|
populate `Source_Object` — not a fixable bug). Evidence:
|
||||||
|
`docs/plans/2026-06-27-otopcua-historian-followups.md`.
|
||||||
|
|
||||||
|
Kept here because it is the recipe to **re-run** the gate after any gateway or backend change (VPN to
|
||||||
|
`wonder-sql-vd03`, gateway running the prerequisites above):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222 # absolute gateway URI; absent ⇒ all live tests skip
|
||||||
|
export HISTGW_GATEWAY_APIKEY=histgw_<id>_<secret> # must carry historian:read + historian:write (+ tags:write) scopes
|
||||||
|
export HISTGW_TEST_TAG=<existing-tag> # read round-trip
|
||||||
|
export HISTGW_WRITE_SANDBOX_TAG=<writable-float-tag> # e.g. HistGW.LiveTest.Sandbox — write round-trip (EnsureTags + write)
|
||||||
|
export HISTGW_BOOL_SANDBOX_TAG=<writable-bool-tag> # e.g. HistGW.LiveTest.BoolSandbox — Boolean→Int2 EnsureTags leg + retype probe (issue #441)
|
||||||
|
export HISTGW_ALARM_SOURCE=<source-name> # alarm SendEvent → ReadEvents round-trip
|
||||||
|
dotnet test --filter "Category=LiveIntegration"
|
||||||
|
```
|
||||||
|
|
||||||
|
The live suite **skips cleanly** when these env vars are absent (safe to run offline on macOS). It is the
|
||||||
|
gate the operator runs on the VPN before trusting the cutover.
|
||||||
|
|
||||||
|
### Continuous-historization value capture — wired and live-gated (was "KNOWN LIMITATION 2"; #491 CLOSED 2026-08-04)
|
||||||
|
|
||||||
|
The `ContinuousHistorizationRecorder` is fully wired (actor + FasterLog outbox + gateway value-writer +
|
||||||
|
meters). It is spawned with an **empty *initial* ref set** (`Array.Empty<string>()` in
|
||||||
|
`WithOtOpcUaRuntimeActors`) because the deployed address space — and thus the historized-ref set — is built
|
||||||
|
later at deploy time, not at actor-spawn time. **The ref-feed gap is now closed:** on every deploy the
|
||||||
|
`AddressSpaceApplier.FeedHistorizedRefs` hook computes the added/removed historized-ref delta and posts it
|
||||||
|
through `ActorHistorizedTagSubscriptionSink` → the recorder's `UpdateHistorizedRefs` message, and the
|
||||||
|
recorder keeps the full set and re-registers its dependency-mux interest. So the recorder converges to the
|
||||||
|
deployed set of historized value tags from the first deploy onward (the feed is a non-blocking, deploy-safe
|
||||||
|
`Tell`; a faulting feed can never break a deploy).
|
||||||
|
|
||||||
|
**The live gate RAN 2026-08-01 against a real HistorianGateway + a real AVEVA Historian, and it found a
|
||||||
|
production-fatal defect** — see `docs/plans/2026-08-01-continuous-historization-live-gate.md`. Three things
|
||||||
|
to know:
|
||||||
|
|
||||||
|
- ⚠️ **`ConfigureAwait(false)` in an Akka handler destroys `Self`.** `OnValueChangedAsync` touched `Self`
|
||||||
|
after awaiting `IHistorizationOutbox.AppendAsync(...).ConfigureAwait(false)`, which discards Akka's
|
||||||
|
`ActorTaskScheduler` synchronization context — so `ActorBase.Context` was gone and it threw
|
||||||
|
`NotSupportedException` on the FIRST value. The throw reached supervision, the actor **restarted**, and
|
||||||
|
because production seeds an **empty** ref set the restart discarded every historized ref: one value
|
||||||
|
captured, then silence. Fixed by capturing `IActorRef self = Self` before the await (the idiom the
|
||||||
|
`GetStatus` handler already used for `Sender`).
|
||||||
|
- ⚠️ **A fake that completes synchronously hides this entire class of bug**, and 13 tests passed because
|
||||||
|
of one. `ValueTask.CompletedTask` never yields, so the continuation ran inline with the context intact.
|
||||||
|
Worse, the obvious fix to the fake — `await Task.Yield()` — is *also* vacuous: `Yield()` posts back to
|
||||||
|
the captured context, so the caller still resumes on the actor thread. The fake must complete on a
|
||||||
|
**pool** thread (`await Task.Delay(1).ConfigureAwait(false)`), as a real per-entry fsync does. Only
|
||||||
|
reverting the production fix exposed that the first regression test proved nothing.
|
||||||
|
- **Both pair nodes historized the same value** — the recorder had **no Primary gate**, unlike the alarm
|
||||||
|
store-and-forward sink, so a redundant pair sent duplicate live values per historized tag. Filed as
|
||||||
|
**#537** and **fixed** — see the paragraph below.
|
||||||
|
|
||||||
|
**Gate COMPLETE — #491 CLOSED 2026-08-04.** Outbox append, drain, `WriteLiveValues`,
|
||||||
|
**restart-convergence**, `EnsureTags` provisioning (`ensured=2 failed=0`) and **read-back** all ✅. The
|
||||||
|
read-back leg ran against the licensed `wonder-sql-vd03` with the VPN up: two registers carrying
|
||||||
|
*distinct non-zero* constants (`8224`, `120`) each read back as its own value — a `0`-valued source
|
||||||
|
would have proved nothing (the #536 trap).
|
||||||
|
|
||||||
|
**Both gate findings are now CLOSED and separately live-gated — #537 (Primary gate) and #538 (poison
|
||||||
|
tag); evidence in the same plan doc. Two things to know before touching either.**
|
||||||
|
|
||||||
|
- **#538 — the discriminator is "did another tag write this pass?", NOT a failure count.** `DrainOnceAsync`
|
||||||
|
writes every per-tag group and acks **all-or-nothing** (deliberate: `RemoveAsync` truncates *through* an
|
||||||
|
id, so partial acking could drop older unwritten entries). One permanently-failing tag therefore pinned
|
||||||
|
the FIFO head while every *healthy* tag was re-submitted on every pass, forever — duplicate samples, an
|
||||||
|
outbox that never truncated (observed 8.3 MB; signature = the gateway's `ok` **and** `error` counters
|
||||||
|
climbing together). The drain now quarantines a tag after `MaxWriteAttempts` consecutive failures, but
|
||||||
|
⚠️ **only counts a failure on a pass where some other tag succeeded** — otherwise an ordinary gateway
|
||||||
|
restart (where everything fails) would discard the entire backlog, converting a duplicate-samples bug
|
||||||
|
into a lost-history one. A count-only quarantine passes the poison-tag test and fails nothing else, so
|
||||||
|
the load-bearing test is the **falsification control** (`A_backend_that_fails_every_tag_abandons_nothing`).
|
||||||
|
Abandonment is also deferred until *every* failing tag is over budget, so nothing is discarded on a pass
|
||||||
|
that could not have advanced the head anyway.
|
||||||
|
- **#537 — it gates the CAPTURE, not the drain, and that is the opposite of the alarm sink.** The alarm
|
||||||
|
queue is in LocalDb and *replicates*, so the Secondary holds the Primary's rows and gating the drain
|
||||||
|
loses nothing. The historization outbox is a node-local FasterLog replicating to nobody: gating only its
|
||||||
|
drain leaves the standby appending forever to a queue no one empties (unbounded disk, then a flood of
|
||||||
|
stale duplicates on promotion). ⚠️ **And the peer that matters is one recording the SAME refs, not one
|
||||||
|
holding the same rows** — a node stands down only for a Primary in its own per-cluster mesh, and a node
|
||||||
|
with no cluster-scoped role never stands down at all. Under a fleet-wide election the elected Primary is
|
||||||
|
usually running another cluster's configuration, and deferring to it ends this cluster's historization
|
||||||
|
*silently* (the drain reports success on an empty outbox forever) — the same scope mismatch that once
|
||||||
|
suspended the alarm drain on every node of the rig at once. An unknown role captures. New verdict on
|
||||||
|
`RedundancyRoleView` behind **`IHistorizationRoleView`**, registered unconditionally on driver nodes
|
||||||
|
(NOT alongside the alarm `IRedundancyRoleView`, which only exists when that section is enabled — the
|
||||||
|
#511 registration accident).
|
||||||
|
- **#672 — an UNREACHABLE recording Primary does not count (crash-failover gap).** A SIGKILLed Primary
|
||||||
|
stays a member (unreachable, not downed) for 10 s failure detection + 15 s auto-down, and the
|
||||||
|
redundancy singleton died with it, so the standby's cached snapshot kept naming it Primary and
|
||||||
|
*nobody* captured for ~30 s. `DriverHostActor` now holds its **own** `ReachabilityEvent` (+`MemberUp`)
|
||||||
|
subscription — not inside `StartPrimaryVerdictPublishing`, which early-returns with no primary-role
|
||||||
|
source — handled in **all three** behaviours, caches the last snapshot, and on a reachability change
|
||||||
|
re-derives and republishes **only** the historization verdict with unreachable peers excluded. Gap ⇒ the
|
||||||
|
failure-detector window (~10–12 s). Gated by `ContinuousHistorization:CaptureWhilePrimaryUnreachable`
|
||||||
|
(default `true`). ⚠️ **The alarm drain and `ShouldServiceAsPrimary`/`PublishPrimaryVerdict` must NOT
|
||||||
|
adopt this** (a false allow there is a dual claimant, not a duplicate) — the tests assert both stay
|
||||||
|
put. ⚠️ A **removed** member deliberately stays marked unreachable (only `MemberUp` at that address
|
||||||
|
clears it): un-marking it would re-derive from a cached snapshot still naming it Primary and close the
|
||||||
|
gate on a dead node. Cost: duplicates while a live Primary is merely unreachable and under a real
|
||||||
|
partition — idempotent on SqlServer/QuestDB only for source-timestamped drivers (Modbus stamps each
|
||||||
|
node's own poll time ⇒ duplicate rows on every backend); Gateway stores duplicates.
|
||||||
|
|
||||||
|
Re-confirmed live in 2026-06 and **since fixed (#600.1)**: `EnsureTags` provisioning used to fire only for
|
||||||
|
newly-ADDED historized tags, so flipping an existing tag's `historianTagname` (or historization, or DataType)
|
||||||
|
provisioned nothing (`2026-06-27-historian-gateway-integration-issues.md` Issue 5). It now also fires for those
|
||||||
|
changed raw tags.
|
||||||
+19
-22
@@ -1,7 +1,6 @@
|
|||||||
# CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master
|
# CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master
|
||||||
# branches. Layered into two jobs:
|
# branches. One job restores and builds once, then tests the WHOLE solution
|
||||||
# build dotnet restore + build (fast feedback on compile errors)
|
# minus the env-gated tiers (self-maintaining).
|
||||||
# unit-tests the WHOLE solution minus the env-gated tiers (self-maintaining)
|
|
||||||
#
|
#
|
||||||
# Skips E2E (Category=E2E) and LiveIntegration (needs a real gateway/PLC/VPN) — both
|
# Skips E2E (Category=E2E) and LiveIntegration (needs a real gateway/PLC/VPN) — both
|
||||||
# are env-gated tiers with no hosted-runner fixtures. (The skip-gated *.IntegrationTests
|
# are env-gated tiers with no hosted-runner fixtures. (The skip-gated *.IntegrationTests
|
||||||
@@ -20,38 +19,35 @@ on:
|
|||||||
branches: [v2-akka-fuse, master]
|
branches: [v2-akka-fuse, master]
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
# #707: one active run per repository across push/PR triggers. Gitea 1.26 supports
|
||||||
|
# workflow concurrency; newer pending runs can supersede an older pending run.
|
||||||
|
concurrency:
|
||||||
|
group: lmxopcua-v2-ci
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_NOLOGO: "1"
|
DOTNET_NOLOGO: "1"
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
build:
|
unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.0.x
|
dotnet-version: 10.0.x
|
||||||
# #606: code-reviews/README.md must be regenerated from the per-module findings.md
|
# #606: regenerated review summary must remain in sync with module findings.
|
||||||
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
|
|
||||||
- name: code-reviews README drift check
|
- name: code-reviews README drift check
|
||||||
run: python3 code-reviews/regen-readme.py --check
|
run: python3 code-reviews/regen-readme.py --check
|
||||||
|
- name: TRX skip guard fixtures
|
||||||
|
run: python3 scripts/ci/test-assert-not-all-skipped.py
|
||||||
|
# #707: bound project workers and avoid concurrent NuGet restore work.
|
||||||
- name: dotnet restore
|
- name: dotnet restore
|
||||||
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
|
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx --disable-parallel -m:2
|
||||||
- name: dotnet build
|
- name: dotnet build
|
||||||
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release
|
run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release -m:2
|
||||||
|
|
||||||
unit-tests:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 10.0.x
|
|
||||||
- name: dotnet restore
|
|
||||||
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
|
|
||||||
# #606: the runner's size is the context every timing-sensitive failure needs and the log never
|
# #606: the runner's size is the context every timing-sensitive failure needs and the log never
|
||||||
# recorded it (load average included: the two Gitea runners may be running jobs side by side).
|
# recorded it (load average included: the two Gitea runners may be running jobs side by side).
|
||||||
- name: runner capacity
|
- name: runner capacity
|
||||||
@@ -69,14 +65,15 @@ jobs:
|
|||||||
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
|
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
|
||||||
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
|
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
|
||||||
# #606: Akka TestKit time dilation for the Runtime + ControlPlane actor suites (read by their
|
# #606: Akka TestKit time dilation for the Runtime + ControlPlane actor suites (read by their
|
||||||
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). This leg runs every test assembly at once
|
# harnesses, tests/Server/*/Harness/TestTimeFactor.cs). Their 2–8 s presence budgets
|
||||||
# on the runner, and their 2–8 s presence budgets — sized on an idle dev box — failed in a long
|
# — sized on an idle dev box — failed in a long
|
||||||
# tail, a different test per run. Dilation only stretches waits: a presence wait still fails if
|
# tail, a different test per run. Dilation only stretches waits: a presence wait still fails if
|
||||||
# the message never comes, and absence windows get longer (stricter). Local runs stay at 1.
|
# the message never comes, and absence windows get longer (stricter). Local runs stay at 1.
|
||||||
env:
|
env:
|
||||||
AKKA_TEST_TIMEFACTOR: "3"
|
AKKA_TEST_TIMEFACTOR: "3"
|
||||||
run: >
|
run: >
|
||||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-restore
|
dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-build
|
||||||
|
-m:1 -p:TestTfmsInParallel=false --settings scripts/ci/ci.runsettings
|
||||||
--filter "Category!=E2E&Category!=LiveIntegration"
|
--filter "Category!=E2E&Category!=LiveIntegration"
|
||||||
--logger "trx" --results-directory artifacts/trx
|
--logger "trx" --results-directory artifacts/trx
|
||||||
# 07/S-2 fail-on-skip: a whole-solution run that executed ZERO tests is a masked outage
|
# 07/S-2 fail-on-skip: a whole-solution run that executed ZERO tests is a masked outage
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,8 @@ packages/
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||||
.claude/
|
.claude/*
|
||||||
|
!.claude/rules/
|
||||||
|
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ Ordering follows from that: the drain reads in `enqueued_at_utc` order (with `id
|
|||||||
as a tiebreak so the ordering is total), because a hashed key carries no
|
as a tiebreak so the ordering is total), because a hashed key carries no
|
||||||
insertion sequence.
|
insertion sequence.
|
||||||
|
|
||||||
|
Gateway delivery carries a separate nonzero `DeliveryId` GUID. Direct event instances
|
||||||
|
create it once; retries and record copies preserve it. It is excluded from queue JSON
|
||||||
|
and business equality, so the existing payload fingerprint and pair deduplication stay
|
||||||
|
unchanged. The drain derives its delivery GUID solely from the persisted row `id`,
|
||||||
|
including migrated legacy rows. It never hashes a reconstructed payload or mints an ID
|
||||||
|
per send. The versioned encoding in `AlarmDeliveryIds` therefore survives retry,
|
||||||
|
restart, payload edits and replicated failover without a schema migration.
|
||||||
|
|
||||||
|
Different business payloads at the same timestamp remain distinct; completely identical
|
||||||
|
payloads deliberately converge. An explicitly empty direct delivery ID is rejected.
|
||||||
|
The Gateway mapper sends this GUID as the wire event ID; the live alarm readback test
|
||||||
|
requires that exact ID. SQL Server and QuestDB retain their existing event-ID schemes.
|
||||||
|
|
||||||
`EnqueueAsync` does a single `INSERT … ON CONFLICT DO NOTHING` on the hot path.
|
`EnqueueAsync` does a single `INSERT … ON CONFLICT DO NOTHING` on the hot path.
|
||||||
To avoid a `SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory
|
To avoid a `SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory
|
||||||
non-dead-lettered row counter (seeded at startup, kept current by every mutation,
|
non-dead-lettered row counter (seeded at startup, kept current by every mutation,
|
||||||
|
|||||||
@@ -456,9 +456,15 @@ from the scripted `AlarmCommandRouter`:
|
|||||||
carrying the authored `FullName` as the `ConditionId` and the
|
carrying the authored `FullName` as the `ConditionId` and the
|
||||||
authenticated `OperatorUser`. The driver forwards this to the Galaxy
|
authenticated `OperatorUser`. The driver forwards this to the Galaxy
|
||||||
gateway → AVEVA via `GatewayGalaxyAlarmAcknowledger`.
|
gateway → AVEVA via `GatewayGalaxyAlarmAcknowledger`.
|
||||||
**Fire-and-forget** — a failed upstream ack is not surfaced back to the
|
The Galaxy adapter awaits the correlated `AcknowledgeAlarmAsync` RPC:
|
||||||
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
|
gateway protocol failures throw, while a non-zero native return code is
|
||||||
`AlarmConditionState` SDK update already committed at step 1).
|
warning-logged. **The actor route back to OPC UA is fire-and-forget** —
|
||||||
|
`DriverInstanceActor` logs and drops failures without replying to the
|
||||||
|
OPC UA client, whose local `AlarmConditionState` SDK update already
|
||||||
|
committed at step 1. This is a native-ack routing limitation; Galaxy
|
||||||
|
value writes have a separate correlated write-outcome path. An awaited
|
||||||
|
ack RPC does not prove the ack reached AVEVA event history: the H6 live
|
||||||
|
verification remains [operator-gated](plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md).
|
||||||
|
|
||||||
Only the **Acknowledge** is routed to the driver. **A native condition
|
Only the **Acknowledge** is routed to the driver. **A native condition
|
||||||
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
||||||
|
|||||||
@@ -325,8 +325,10 @@ routes through a separate `NativeAlarmAckRouter` seam (instead of the scripted
|
|||||||
FullName)` inverse map, Primary-gated) → the owning driver's
|
FullName)` inverse map, Primary-gated) → the owning driver's
|
||||||
`IAlarmSource.AcknowledgeAsync` → Galaxy gateway → AVEVA. The call carries the
|
`IAlarmSource.AcknowledgeAsync` → Galaxy gateway → AVEVA. The call carries the
|
||||||
authenticated operator's display name via the `AlarmAcknowledgeRequest.OperatorUser`
|
authenticated operator's display name via the `AlarmAcknowledgeRequest.OperatorUser`
|
||||||
field. This is fire-and-forget — a failed upstream ack is not surfaced back to the
|
field. The actor route back to OPC UA is fire-and-forget — a failed upstream ack
|
||||||
OPC UA client (mirrors the Galaxy write-outcome limitation). See [AlarmTracking.md
|
is logged rather than surfaced back to the OPC UA client. The Galaxy adapter
|
||||||
|
awaits its correlated RPC; this does not establish delivery to AVEVA event history.
|
||||||
|
Galaxy value writes use a separate correlated write-outcome path. See [AlarmTracking.md
|
||||||
§Native alarm acknowledge → AVEVA](AlarmTracking.md#native-alarm-acknowledge--aveva)
|
§Native alarm acknowledge → AVEVA](AlarmTracking.md#native-alarm-acknowledge--aveva)
|
||||||
for the full routing diagram.
|
for the full routing diagram.
|
||||||
|
|
||||||
|
|||||||
+45
-7
@@ -198,15 +198,53 @@ Timers are **read-only** this phase; a write attempt returns `BadNotWritable`.
|
|||||||
### Counter read
|
### Counter read
|
||||||
|
|
||||||
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is
|
**Address**: `C{n}` (e.g. `C0`, `C10`). **DataType must be `Int32`**. The OPC UA node is
|
||||||
a scalar `Int32` whose value is the current **count** (raw word from the PLC).
|
a scalar `Int32` containing the unsigned **raw big-endian word**, widened to `Int32`:
|
||||||
|
`(bytes[0] << 8) | bytes[1]`. `S7.Net`'s `Counter.FromByteArray` performs no BCD
|
||||||
|
reinterpretation; for example, bytes `01 23` surface as `291`. This describes the
|
||||||
|
current decoder, not verified agreement with a PLC's displayed count.
|
||||||
Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
|
Counters are **read-only** this phase; a write attempt returns `BadNotWritable`.
|
||||||
|
|
||||||
> **Known limitation — Counter BCD encoding on S7-300/400**: `S7.Net`'s
|
The S7comm ReadVar **C-area response encoding remains unverified on hardware**.
|
||||||
> `Counter.FromByteArray` returns the raw big-endian word without BCD decode.
|
PLC instruction representations do not by themselves establish network encoding:
|
||||||
> On **classic S7-300/400** the C-area word is BCD-encoded (0–999), so the
|
Siemens documents a binary current value for [L: Load counter](https://docs.tia.siemens.cloud/r/en-us/v21/stl-s7-300-s7-400/stl-mnemonic-s7-300-s7-400/counter-operations-s7-300-s7-400/l-load-counter-s7-300-s7-400)
|
||||||
> surfaced value can differ from the actual count on that hardware. S7-1200/1500
|
and separate `CV` and `CV_BCD` outputs for [S_CD](https://docs.tia.siemens.cloud/r/en-us/v21/lad-s7-300-s7-400/counter-operations-s7-300-s7-400/simatic-counters-s7-300-s7-400/s_cd-assign-parameters-and-count-down-s7-300-s7-400?contentId=reR_xapFX_Ike2opHIvfzg).
|
||||||
> use IEC/DB counters (plain integers) where the raw word is correct. BCD
|
|
||||||
> reinterpretation for legacy C-area is a live-hardware-gated follow-up.
|
IEC counter values stored in DB memory use the **DB address and declared integer
|
||||||
|
type**, a different read path from `C{n}`. CPU family alone does not select the
|
||||||
|
counter kind or prove its wire representation. In particular, Siemens also lists
|
||||||
|
legacy SIMATIC counters for S7-1500 in its [instruction comparison (pages 23–24)](https://cache.industry.siemens.com/dl/files/777/109986777/att_1322640/v1/s7_1500_compare_table_en.pdf?download=true).
|
||||||
|
The driver has no counter encoding selector; hardware-faithful counter support
|
||||||
|
requires independent raw-response evidence before any interpretation change.
|
||||||
|
|
||||||
|
### Counter evidence follow-up (#616)
|
||||||
|
|
||||||
|
The existing `S7_1500SpanReadTests` exercises the python-snap7 profile's synthetic
|
||||||
|
raw words (`C0 = 4711`, `C7 = 31337`). These are outside the classic SIMATIC count
|
||||||
|
range `0..999` documented by S_CD. These expectations describe raw-word handling;
|
||||||
|
they do not establish classic counter count semantics. The recorded device-0 run cannot prove nonzero
|
||||||
|
numbering. The nonzero test is environment-gated, but its options remain
|
||||||
|
`CpuType = S71500`, rack `0`, slot `0`: setting `S7_TIMER_NUMBER_ADDRESSING=1`
|
||||||
|
does not configure a classic CPU or make the profile a classic counter gate.
|
||||||
|
|
||||||
|
Before claiming hardware validation or closing #616:
|
||||||
|
|
||||||
|
- Prepare a separate target-specific semantic gate with the actual CPU type,
|
||||||
|
rack, slot and TSAP/connection settings. Keep the synthetic raw-word profile
|
||||||
|
as its own evidence of the existing decoder contract.
|
||||||
|
- Record CPU model/order number, firmware, endpoint, connection settings and
|
||||||
|
driver/S7netplus plus simulator versions where applicable.
|
||||||
|
- Observe independent PLC-side current counts and binary/BCD reference outputs
|
||||||
|
for valid classic counts such as `123`, a second distinguishing count (`42`
|
||||||
|
or `999`), and zero. Record the instruction/counter kind and DB reference
|
||||||
|
addresses; do not derive reference values from the driver's output.
|
||||||
|
- Capture the actual two-byte S7comm C-area responses alongside those references.
|
||||||
|
Do not infer response bytes from a simulator seed or expected BCD value.
|
||||||
|
- Verify several distinct nonzero timer/counter numbers against their independent
|
||||||
|
PLC values and captured requests/responses, and verify the read-only contract.
|
||||||
|
|
||||||
|
The raw-byte/count and nonzero-numbering gates remain open. Password/protection
|
||||||
|
support inherited from #303/#632 is a separate follow-up, not evidence supplied
|
||||||
|
by these counter checks.
|
||||||
|
|
||||||
### Deferrals (not yet implemented)
|
### Deferrals (not yet implemented)
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ OPC UA client Acknowledge on a NATIVE condition
|
|||||||
under Lock, like `AlarmCommandRouter`).
|
under Lock, like `AlarmCommandRouter`).
|
||||||
- **Driver faulted / reconnecting** → `AcknowledgeAsync` fails or no-ops per the driver; the local condition
|
- **Driver faulted / reconnecting** → `AcknowledgeAsync` fails or no-ops per the driver; the local condition
|
||||||
state still reflects the SDK-applied ack (the OPC UA client sees Good). Surfacing a *failed* upstream ack
|
state still reflects the SDK-applied ack (the OPC UA client sees Good). Surfacing a *failed* upstream ack
|
||||||
back to the client is out of scope (mirrors the Galaxy write-outcome fire-and-forget limitation).
|
back to the client is out of scope for this native-ack actor route. **Current clarification
|
||||||
|
(2026-10-13):** the Galaxy adapter awaits a correlated ack RPC, but the actor route does not
|
||||||
|
return its outcome to OPC UA; Galaxy value writes have a separate correlated write-outcome path.
|
||||||
- **H4 native Enable/Disable** → `BadNotSupported` (decision #2).
|
- **H4 native Enable/Disable** → `BadNotSupported` (decision #2).
|
||||||
- **H2-bit:** no runtime behavior change for existing grants except closing the HistoryRead⇒HistoryUpdate
|
- **H2-bit:** no runtime behavior change for existing grants except closing the HistoryRead⇒HistoryUpdate
|
||||||
authorization hole; the service itself stays rejected.
|
authorization hole; the service itself stays rejected.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Alarm delivery identity results — lmxopcua #705
|
||||||
|
|
||||||
|
Alarm events now carry a nonzero delivery GUID created once for direct delivery and preserved across retries and record copies. It is excluded from queue JSON and business-field equality/hash. Queued delivery overrides it with a frozen versioned UUID encoding derived only from the persisted queue row key, preserving existing payload JSON, fingerprints, schema, deduplication and AlarmId. The Gateway mapper sends that GUID and rejects explicitly empty identities. SQL Server and QuestDB identity logic is unchanged.
|
||||||
|
|
||||||
|
Coverage includes literal historical JSON/fingerprint/GUID vectors; ten-field equality and hash behavior; direct copies/retries; queue retry/reopen/payload edits/dead-letter revival; identical-input convergence; distinct same-time business events; real replicated queue convergence; and legacy migration. Reconstructed direct objects receive new identities; durable reconstruction uses the queue. Completely identical business payloads remain intentionally deduplicated.
|
||||||
|
|
||||||
|
TDD: direct wire regression failed on an empty ID; queued retry/reopen regression failed on changing IDs. Final affected runs: Core51/51, Host migration/convergence16/16, Gateway453passed/6live skipped/0failed. Full offline solution:14,635passed/611skipped/0failed across62suites, exit0. Independent final review and diffcheck clear. Fixture configuration variables were removed before all runs.
|
||||||
|
|
||||||
|
No live event writes, deployed sender rollout or historical row rewrite occurred. The live round-trip test now requires exact delivery GUID readback; actual persistence remains the open Gateway #28 gate, alongside its separate #39 timestamp-offset provider failure. Upstream History controls #29 and lmxopcua #694/#700 remain open.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Whole-solution CI
|
||||||
|
|
||||||
|
The `v2-ci` workflow restores, builds and tests in one job so the test step uses
|
||||||
|
that job's Release binaries. Restore disables parallel package work; restore and
|
||||||
|
build use at most two MSBuild workers. Testing uses one MSBuild worker, disables
|
||||||
|
parallel target-framework runs and loads `ci.runsettings`: one VSTest process and
|
||||||
|
two xUnit collection workers with conservative scheduling. Assemblies that disable
|
||||||
|
parallelization keep that setting.
|
||||||
|
|
||||||
|
The workflow retains the E2E/LiveIntegration exclusions, includes SlowUnit, keeps
|
||||||
|
Akka's test time factor at three, and requires at least 12,000 executed tests.
|
||||||
|
Runner capacity and TRX files remain available for diagnosing failures. These
|
||||||
|
limits reduce contention; they do not prove every timing failure has one cause.
|
||||||
|
There are no automatic test retries or changed assertion deadlines.
|
||||||
|
|
||||||
|
Gitea 1.26 supports the repository-wide `lmxopcua-v2-ci` concurrency group. Only
|
||||||
|
one run is active; an active run is not cancelled, but a newer pending run may
|
||||||
|
supersede an older pending run. The group does not serialize other repositories
|
||||||
|
using the same host.
|
||||||
|
|
||||||
|
To reproduce the bounded test leg after restore and Release build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AKKA_TEST_TIMEFACTOR=3 dotnet test ZB.MOM.WW.OtOpcUa.slnx \
|
||||||
|
--configuration Release --no-build -m:1 -p:TestTfmsInParallel=false \
|
||||||
|
--settings scripts/ci/ci.runsettings \
|
||||||
|
--filter "Category!=E2E&Category!=LiveIntegration" \
|
||||||
|
--logger trx --results-directory artifacts/trx
|
||||||
|
MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh artifacts/trx/*.trx
|
||||||
|
```
|
||||||
|
|
||||||
|
The guard uses the existing TRX `Counters/@executed` value for the aggregate
|
||||||
|
floor. Its `skipped` diagnostic counts individual `Results/UnitTestResult`
|
||||||
|
records whose outcome is `NotExecuted`, using the document's XML namespace.
|
||||||
|
Some VSTest reports set summary `notExecuted` to zero even when these records
|
||||||
|
are present. Skip records are counted per file, including repeated runs; they
|
||||||
|
are not a count of unique test methods and do not affect the executed floor.
|
||||||
|
For malformed XML, the diagnostic retains the old summary-counter fallback;
|
||||||
|
this guard does not add XML validation to its existing gate policy.
|
||||||
|
|
||||||
|
Run the deterministic script fixtures without building or running the solution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/ci/test-assert-not-all-skipped.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified hosted baseline: [Gitea run 975](https://gitea.dohertylan.com/dohertj2/lmxopcua/actions/runs/975)
|
||||||
|
completed successfully on 2026-10-13 for commit
|
||||||
|
`76bdca90e94e814e06ef10b78271e2b492b2ba32` (merged by PR #711).
|
||||||
|
Restore, Release build, README drift check, the serialized whole-solution test
|
||||||
|
step, the 12,000-test floor and TRX upload all passed. The floor counted
|
||||||
|
14,630 executed tests across 62 TRX files. This baseline predates the individual
|
||||||
|
skip-reporting correction; its printed zero skip diagnostic is not skip evidence.
|
||||||
@@ -33,12 +33,31 @@ for f in "$@"; do
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
seen=$((seen + 1))
|
seen=$((seen + 1))
|
||||||
# The trx <Counters .../> element in <ResultSummary> carries executed + notExecuted counts.
|
# Keep the executed-counter floor unchanged; notExecuted is only a fallback
|
||||||
|
# for malformed XML that this guard historically accepted.
|
||||||
counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
|
counters="$(grep -oE '<Counters[^>]*/?>' "$f" | head -1)"
|
||||||
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)"
|
ex="$(printf '%s' "$counters" | grep -oE 'executed="[0-9]+"' | grep -oE '[0-9]+' || true)"
|
||||||
ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
|
ne="$(printf '%s' "$counters" | grep -oE 'notExecuted="[0-9]+"' | grep -oE '[0-9]+' || true)"
|
||||||
ex="${ex:-0}"
|
ex="${ex:-0}"
|
||||||
ne="${ne:-0}"
|
ne="${ne:-0}"
|
||||||
|
# VSTest can emit notExecuted="0" despite individual NotExecuted results.
|
||||||
|
# Count direct Results/UnitTestResult records in the document's namespace,
|
||||||
|
# excluding foreign elements, nested records and escaped diagnostic text.
|
||||||
|
ne="$(python3 - "$f" "$ne" <<'PY'
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.parse(sys.argv[1]).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
# Skip reporting must not introduce a new malformed-XML gate failure.
|
||||||
|
print(sys.argv[2])
|
||||||
|
else:
|
||||||
|
namespace = root.tag.split("}", 1)[0] + "}" if root.tag.startswith("{") else ""
|
||||||
|
results = root.findall(f"{namespace}Results/{namespace}UnitTestResult")
|
||||||
|
print(sum(result.get("outcome") == "NotExecuted" for result in results))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
total_executed=$((total_executed + ex))
|
total_executed=$((total_executed + ex))
|
||||||
total_skipped=$((total_skipped + ne))
|
total_skipped=$((total_skipped + ne))
|
||||||
echo " $(basename "$f"): executed=${ex} skipped=${ne}"
|
echo " $(basename "$f"): executed=${ex} skipped=${ne}"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RunSettings>
|
||||||
|
<!-- #707: project scheduling is also bounded by dotnet test -m:1. -->
|
||||||
|
<RunConfiguration>
|
||||||
|
<MaxCpuCount>1</MaxCpuCount>
|
||||||
|
</RunConfiguration>
|
||||||
|
<xUnit>
|
||||||
|
<!-- Preserve each assembly's parallelization opt-out; bound enabled collections. -->
|
||||||
|
<MaxParallelThreads>2</MaxParallelThreads>
|
||||||
|
<ParallelAlgorithm>conservative</ParallelAlgorithm>
|
||||||
|
</xUnit>
|
||||||
|
</RunSettings>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic subprocess checks for the TRX executed-floor guard."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).with_name("assert-not-all-skipped.sh")
|
||||||
|
NS = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"
|
||||||
|
|
||||||
|
|
||||||
|
def trx(executed, outcomes, summary_skipped=0, namespace=NS, extra=""):
|
||||||
|
declaration = f' xmlns="{namespace}"' if namespace else ""
|
||||||
|
results = "".join(f'<UnitTestResult outcome="{outcome}"/>' for outcome in outcomes)
|
||||||
|
return (f'<TestRun{declaration}><Results>{results}{extra}</Results>'
|
||||||
|
f'<ResultSummary><Counters total="{len(outcomes)}" executed="{executed}" '
|
||||||
|
f'notExecuted="{summary_skipped}"/></ResultSummary></TestRun>')
|
||||||
|
|
||||||
|
|
||||||
|
class SkipGuardTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory(prefix="trx-skip-fixtures-")
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.root = Path(self.directory.name)
|
||||||
|
|
||||||
|
def write(self, name, content):
|
||||||
|
path = self.root / name
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def run_guard(self, *paths, minimum=None):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("MIN_EXECUTED", None)
|
||||||
|
if minimum is not None:
|
||||||
|
env["MIN_EXECUTED"] = str(minimum)
|
||||||
|
return subprocess.run(["bash", str(SCRIPT), *map(str, paths)], env=env,
|
||||||
|
text=True, capture_output=True, check=False)
|
||||||
|
|
||||||
|
def assert_report(self, result, status, executed, skipped, files=1):
|
||||||
|
self.assertEqual(status, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertIn(f"files={files} executed={executed} skipped={skipped} ", result.stdout)
|
||||||
|
|
||||||
|
def test_mixed_results_ignore_zero_summary_skip_counter(self):
|
||||||
|
# A summary-only skip count misses the three actual NotExecuted records.
|
||||||
|
path = self.write("mixed.trx", trx(827, ["Passed"] * 827 + ["NotExecuted"] * 3))
|
||||||
|
result = self.run_guard(path)
|
||||||
|
self.assert_report(result, 0, 827, 3)
|
||||||
|
self.assertIn("mixed.trx: executed=827 skipped=3", result.stdout)
|
||||||
|
|
||||||
|
def test_all_skipped_fails_default_floor_but_report_only_passes(self):
|
||||||
|
path = self.write("all-skipped.trx", trx(0, ["NotExecuted"] * 188))
|
||||||
|
self.assert_report(self.run_guard(path), 1, 0, 188)
|
||||||
|
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 188)
|
||||||
|
|
||||||
|
def test_zero_tests_fails_default_floor_but_report_only_passes(self):
|
||||||
|
path = self.write("zero.trx", trx(0, []))
|
||||||
|
self.assert_report(self.run_guard(path), 1, 0, 0)
|
||||||
|
self.assert_report(self.run_guard(path, minimum=0), 0, 0, 0)
|
||||||
|
|
||||||
|
def test_agreeing_summary_and_outcomes_are_not_double_counted(self):
|
||||||
|
path = self.write("agreeing.trx", trx(1, ["Passed", "NotExecuted"], 1))
|
||||||
|
self.assert_report(self.run_guard(path), 0, 1, 1)
|
||||||
|
|
||||||
|
def test_outcomes_are_authoritative_when_summary_overstates_skips(self):
|
||||||
|
path = self.write("overstated.trx", trx(1, ["Passed", "NotExecuted"], 9))
|
||||||
|
self.assert_report(self.run_guard(path), 0, 1, 1)
|
||||||
|
|
||||||
|
def test_namespace_path_and_exact_outcome_exclude_decoys(self):
|
||||||
|
extra = ('<Other><UnitTestResult outcome="NotExecuted"/></Other>'
|
||||||
|
'<foreign:UnitTestResult xmlns:foreign="urn:other" outcome="NotExecuted"/>'
|
||||||
|
'<Output><Message><UnitTestResult outcome="NotExecuted"/></Message></Output>')
|
||||||
|
content = trx(2, ["Passed", "Failed", "NotExecuted", "notexecuted"], extra=extra)
|
||||||
|
# Prefixes on Results/UnitTestResult must work just like the default namespace.
|
||||||
|
content = content.replace('<TestRun ', f'<TestRun xmlns:t="{NS}" ')
|
||||||
|
content = content.replace('<Results>', '<t:Results>').replace('</Results>', '</t:Results>')
|
||||||
|
content = content.replace('<UnitTestResult ', '<t:UnitTestResult ')
|
||||||
|
path = self.write("namespaced.trx", content)
|
||||||
|
self.assert_report(self.run_guard(path), 0, 2, 1)
|
||||||
|
|
||||||
|
def test_unnamespaced_results_remain_supported(self):
|
||||||
|
path = self.write("legacy.trx", trx(1, ["Passed", "NotExecuted"], namespace=""))
|
||||||
|
self.assert_report(self.run_guard(path), 0, 1, 1)
|
||||||
|
|
||||||
|
def test_executed_counter_controls_aggregate_floor_at_boundary(self):
|
||||||
|
# Counters retain authority even when result records disagree with executed.
|
||||||
|
mixed = self.write("floor.trx", trx(12000, ["Passed", "NotExecuted"]))
|
||||||
|
skipped = self.write("skipped.trx", trx(0, ["NotExecuted"]))
|
||||||
|
self.assert_report(self.run_guard(mixed, skipped, minimum=12000), 0, 12000, 2, 2)
|
||||||
|
self.assert_report(self.run_guard(mixed, skipped, minimum=12001), 1, 12000, 2, 2)
|
||||||
|
|
||||||
|
def test_missing_arguments_and_file_keep_exit_two(self):
|
||||||
|
self.assertEqual(2, self.run_guard().returncode)
|
||||||
|
self.assertEqual(2, self.run_guard(self.root / "missing.trx").returncode)
|
||||||
|
|
||||||
|
def test_missing_counters_keeps_existing_exit_one(self):
|
||||||
|
path = self.write("no-counters.trx", '<TestRun><Results/></TestRun>')
|
||||||
|
self.assertEqual(1, self.run_guard(path, minimum=0).returncode)
|
||||||
|
|
||||||
|
def test_malformed_xml_keeps_summary_fallback_and_gate_status(self):
|
||||||
|
path = self.write("malformed.trx", '<TestRun><Counters executed="2" notExecuted="3"/>')
|
||||||
|
self.assert_report(self.run_guard(path, minimum=2), 0, 2, 3)
|
||||||
|
self.assert_report(self.run_guard(path, minimum=3), 1, 2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -56,7 +56,7 @@ public class ReadCommand : CommandBase
|
|||||||
{
|
{
|
||||||
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
|
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
|
||||||
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
|
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
|
||||||
// not crash with a stack trace (#577) — session-level failures still propagate.
|
// not crash with a stack trace — session-level failures still propagate.
|
||||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||||
await console.Output.WriteLineAsync($"Value: ");
|
await console.Output.WriteLineAsync($"Value: ");
|
||||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class DisposeBridge
|
|||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>Runs <paramref name="work" /> to completion (or the timeout), swallowing every failure.</summary>
|
/// <summary>Runs <paramref name="work" /> to completion (or the timeout), swallowing every failure.</summary>
|
||||||
|
/// <param name="work">The asynchronous work to run; it receives a token that is cancelled when the timeout elapses.</param>
|
||||||
public static void Run(Func<CancellationToken, Task> work)
|
public static void Run(Func<CancellationToken, Task> work)
|
||||||
{
|
{
|
||||||
using var cts = new CancellationTokenSource(Timeout);
|
using var cts = new CancellationTokenSource(Timeout);
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
ThrowIfNotConnected();
|
ThrowIfNotConnected();
|
||||||
|
|
||||||
// The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized
|
// The server's ShelvingState child is SDK-created with a numeric id in namespace 0, so a synthesized
|
||||||
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
|
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
|
||||||
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
|
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
|
||||||
// to the condition's ShelvingState methods.
|
// to the condition's ShelvingState methods.
|
||||||
@@ -884,6 +884,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
catch { return false; }
|
catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the event filter (select clauses for the alarm and condition fields) used by alarm subscriptions.</summary>
|
||||||
|
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||||
internal static EventFilter CreateAlarmEventFilter()
|
internal static EventFilter CreateAlarmEventFilter()
|
||||||
{
|
{
|
||||||
var filter = new EventFilter();
|
var filter = new EventFilter();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public sealed class AkkaClusterOptions
|
|||||||
/// <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?)"/>. That fallback was
|
/// <see cref="RoleParser.ResolveEffectiveRoles(IReadOnlyList{string}?)"/>. That fallback was
|
||||||
/// described here — and relied upon in the field — for a year before it was implemented; a
|
/// described here — and relied upon in the field — for a year before it was implemented; a
|
||||||
/// node configured only through the env var joined the cluster carrying no roles at all.
|
/// node configured only through the env var joined the cluster carrying no roles at all.
|
||||||
/// See Gitea #532 and <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
|
/// See <c>docs/deployments/wonder-app-vd03-makino-z-34184.md</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The two sources normalize differently: values bound from <c>Cluster:Roles</c> are taken
|
/// The two sources normalize differently: values bound from <c>Cluster:Roles</c> are taken
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
|||||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
||||||
/// dependencies are safe here. Both <paramref name="effectiveRoles"/> and
|
/// dependencies are safe here. Both <paramref name="effectiveRoles"/> and
|
||||||
/// <paramref name="configuration"/> are optional (default <see langword="null"/>) so a caller
|
/// <paramref name="configuration"/> are optional (default <see langword="null"/>) so a caller
|
||||||
/// with neither to hand — including every test in this file that pre-dates R3-04 — keeps
|
/// with neither to hand — including every test in this file that pre-dates the post-overlay guard — keeps
|
||||||
/// constructing this validator parameterlessly; the post-overlay guard below (choice 4) is
|
/// constructing this validator parameterlessly; the post-overlay guard below (choice 4) is
|
||||||
/// simply skipped when either is null.
|
/// simply skipped when either is null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -116,7 +116,7 @@ public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase<AkkaClust
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R3-04 (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
|
/// Post-overlay guard (choice 4, the residual divergence surface): <c>Program.cs</c> resolves
|
||||||
/// <see cref="EffectiveRoles"/> BEFORE the per-role appsettings overlay loads (the overlay's
|
/// <see cref="EffectiveRoles"/> BEFORE the per-role appsettings overlay loads (the overlay's
|
||||||
/// filename depends on the answer), so if a role overlay — or a <c>${secret:}</c> expansion —
|
/// filename depends on the answer), so if a role overlay — or a <c>${secret:}</c> expansion —
|
||||||
/// ever set <c>Cluster:Roles</c> itself, that value would silently disagree with the
|
/// ever set <c>Cluster:Roles</c> itself, that value would silently disagree with the
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
|||||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <summary>Starts the bootstrap join in the background when the bootstrap guard is enabled.</summary>
|
||||||
|
/// <param name="cancellationToken">Not used; the join runs under the coordinator's own cancellation source.</param>
|
||||||
|
/// <returns>A completed task; the join itself continues asynchronously.</returns>
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_options.BootstrapGuard.Enabled)
|
if (!_options.BootstrapGuard.Enabled)
|
||||||
@@ -59,7 +61,9 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <summary>Cancels the background join and waits for it to finish.</summary>
|
||||||
|
/// <param name="cancellationToken">Not used; shutdown is driven by the coordinator's own cancellation source.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _cts.CancelAsync().ConfigureAwait(false);
|
await _cts.CancelAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public static class ClusterBootstrapGuard
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
|
/// 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
|
/// for the 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"/>
|
/// (single-seed legacy site node, three+ seeds, self absent) returns <see cref="BootstrapRole.Applies"/>
|
||||||
/// = false and the caller joins the configured seeds unchanged.
|
/// = false and the caller joins the configured seeds unchanged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
private readonly string? _clusterRole;
|
private readonly string? _clusterRole;
|
||||||
private readonly string? _clusterId;
|
private readonly string? _clusterId;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
// R3-04 (01/C-9, census row 17) — the two dictionaries below have DIFFERENT correct comparers,
|
// The two dictionaries below have DIFFERENT correct comparers,
|
||||||
// and that split is deliberate, not an oversight (review finding, 2026-08-20).
|
// and that split is deliberate, not an oversight (review finding, 2026-08-20).
|
||||||
//
|
//
|
||||||
// _membersByRole answers "every member carrying (a spelling of) this role" — OrdinalIgnoreCase,
|
// _membersByRole answers "every member carrying (a spelling of) this role" — OrdinalIgnoreCase,
|
||||||
@@ -53,7 +53,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
|
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
|
||||||
// suffix is harmless redundancy.
|
// suffix is harmless redundancy.
|
||||||
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
|
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
|
||||||
// OrdinalIgnoreCase (Gitea #532): Cluster:Roles binds verbatim, so a node authored with 'Driver'
|
// OrdinalIgnoreCase: Cluster:Roles binds verbatim, so a node authored with 'Driver'
|
||||||
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
|
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
|
||||||
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
|
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
|
||||||
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
|
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -128,7 +128,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
// Cluster-002 (review R1 m6): seed only USABLE members. A member already Leaving / Exiting / Down
|
// Seed only USABLE members. A member already Leaving / Exiting / Down
|
||||||
// (or still Joining) at seed time is not one, and a departing member never fires a later
|
// (or still Joining) at seed time is not one, and a departing member never fires a later
|
||||||
// MemberLeft / MemberExited / MemberDowned that HandleMemberEvent would drop it on — it would
|
// MemberLeft / MemberExited / MemberDowned that HandleMemberEvent would drop it on — it would
|
||||||
// linger until MemberRemoved, the very delay the member-event handling exists to avoid. A
|
// linger until MemberRemoved, the very delay the member-event handling exists to avoid. A
|
||||||
@@ -165,7 +165,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
set.Add(up.Member);
|
set.Add(up.Member);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
|
// A leaving / exiting / downed member is no longer a usable member, so
|
||||||
// drop it as soon as that transition lands rather than waiting for MemberRemoved
|
// drop it as soon as that transition lands rather than waiting for MemberRemoved
|
||||||
// (several seconds later). MembersWithRole now has a production caller (the
|
// (several seconds later). MembersWithRole now has a production caller (the
|
||||||
// historization scope-peer check), which must not defer to a departing partner.
|
// historization scope-peer check), which must not defer to a departing partner.
|
||||||
@@ -210,8 +210,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
|
|
||||||
// Record the event's own address. The leader used to be resolved by looking the address up
|
// Record the event's own address. The leader used to be resolved by looking the address up
|
||||||
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
|
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
|
||||||
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
|
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good.
|
||||||
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
|
// Only the address is ever read, so no Member lookup is needed.
|
||||||
_roleLeaders[evt.Role] = evt.Leader;
|
_roleLeaders[evt.Role] = evt.Leader;
|
||||||
if (evt.Leader is not null)
|
if (evt.Leader is not null)
|
||||||
next = ToNodeId(evt.Leader);
|
next = ToNodeId(evt.Leader);
|
||||||
|
|||||||
@@ -21,14 +21,18 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
|
|||||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
||||||
/// constructor dependency is safe here. This validator's only use of configuration is this
|
/// constructor dependency is safe here. This validator's only use of configuration is this
|
||||||
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
||||||
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
|
/// — directly rather than <see cref="IConfiguration"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="effectiveRoles">The node's effective roles, used to decide which fetch settings are required.</param>
|
||||||
public ConfigSourceOptionsValidator(EffectiveRoles effectiveRoles)
|
public ConfigSourceOptionsValidator(EffectiveRoles effectiveRoles)
|
||||||
{
|
{
|
||||||
_effectiveRoles = effectiveRoles;
|
_effectiveRoles = effectiveRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Validates the <see cref="ConfigSourceOptions"/> for this node's roles.</summary>
|
||||||
|
/// <param name="name">The options instance name (unused).</param>
|
||||||
|
/// <param name="options">The options to validate.</param>
|
||||||
|
/// <returns>A success result, or a failure listing every violated rule.</returns>
|
||||||
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
|
public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -92,8 +96,8 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions<ConfigSource
|
|||||||
|
|
||||||
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
|
// Per-cluster mesh Phase 4: a driver-only node has no central ConfigDb connection to fall back
|
||||||
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
|
// on, so Direct is only ever valid on a fused admin+driver node (admin holds the SQL
|
||||||
// connection). EffectiveRoles is the single role authority (archreview R3-04): its precedence
|
// connection). EffectiveRoles is the single role authority: its precedence
|
||||||
// already applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds
|
// already applies the OTOPCUA_ROLES fallback and its normalization already folds
|
||||||
// case, so neither an env-only driver-only node nor one spelled 'Driver' can sail through here
|
// case, so neither an env-only driver-only node nor one spelled 'Driver' can sail through here
|
||||||
// on Direct to read its configuration from nothing.
|
// on Direct to read its configuration from nothing.
|
||||||
var isDriver = _effectiveRoles.HasDriver;
|
var isDriver = _effectiveRoles.HasDriver;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public sealed class EffectiveRoles
|
|||||||
|
|
||||||
/// <summary>True when <paramref name="role"/> is among <see cref="Gossiped"/>, compared case-insensitively.</summary>
|
/// <summary>True when <paramref name="role"/> is among <see cref="Gossiped"/>, compared case-insensitively.</summary>
|
||||||
/// <param name="role">The role to test for.</param>
|
/// <param name="role">The role to test for.</param>
|
||||||
|
/// <returns><see langword="true"/> when the role is present; otherwise <see langword="false"/>.</returns>
|
||||||
public bool Has(string role) =>
|
public bool Has(string role) =>
|
||||||
Gossiped.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
|
Gossiped.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
|||||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so constructor
|
||||||
/// dependencies are safe here. <see cref="IConfiguration"/> is still needed for the
|
/// dependencies are safe here. <see cref="IConfiguration"/> is still needed for the
|
||||||
/// <c>ConfigServe:GrpcListenPort</c> cross-read (the admin-side obligation below) —
|
/// <c>ConfigServe:GrpcListenPort</c> cross-read (the admin-side obligation below) —
|
||||||
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) this rule reads
|
/// <see cref="EffectiveRoles"/> is the single role authority this rule reads
|
||||||
/// roles through instead of re-deriving them.
|
/// roles through instead of re-deriving them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="configuration">The application configuration, read for <c>ConfigServe:GrpcListenPort</c>.</param>
|
/// <param name="configuration">The application configuration, read for <c>ConfigServe:GrpcListenPort</c>.</param>
|
||||||
@@ -34,7 +34,10 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
|||||||
_effectiveRoles = effectiveRoles;
|
_effectiveRoles = effectiveRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Validates the <see cref="MeshTransportOptions"/> for this node's roles.</summary>
|
||||||
|
/// <param name="name">The options instance name (unused).</param>
|
||||||
|
/// <param name="options">The options to validate.</param>
|
||||||
|
/// <returns>A success result, or a failure listing every violated rule.</returns>
|
||||||
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -71,7 +74,7 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
|
|||||||
// dials nodes rather than being dialled, and resolves per-cluster keys at runtime through
|
// dials nodes rather than being dialled, and resolves per-cluster keys at runtime through
|
||||||
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
|
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
|
||||||
// endpoint list of it would demand configuration it never reads. EffectiveRoles is the single
|
// endpoint list of it would demand configuration it never reads. EffectiveRoles is the single
|
||||||
// role authority (archreview R3-04) — its precedence already applies the OTOPCUA_ROLES
|
// role authority — its precedence already applies the OTOPCUA_ROLES
|
||||||
// fallback and its normalization already folds case, so a node configured only through the env
|
// fallback and its normalization already folds case, so a node configured only through the env
|
||||||
// var, or spelled 'Driver', is still the driver that has to dial central.
|
// var, or spelled 'Driver', is still the driver that has to dial central.
|
||||||
var isDriver = _effectiveRoles.HasDriver;
|
var isDriver = _effectiveRoles.HasDriver;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public static class RoleParser
|
|||||||
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
|
/// <summary>Returns true when <paramref name="role"/> is a cluster-scoped role — starts with
|
||||||
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
|
/// <see cref="ClusterRolePrefix"/> and carries a non-empty ClusterId suffix.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The prefix test is <b>case-insensitive</b> (Gitea #532). It was ordinal, which meant a
|
/// The prefix test is <b>case-insensitive</b>. It was ordinal, which meant a
|
||||||
/// <c>Cluster:Roles</c> entry spelled <c>Cluster-SITE-A</c> — that path binds verbatim, unlike
|
/// <c>Cluster:Roles</c> entry spelled <c>Cluster-SITE-A</c> — that path binds verbatim, unlike
|
||||||
/// the <see cref="Parse"/> path, which lower-cases — classified as an ordinary role. The node
|
/// the <see cref="Parse"/> path, which lower-cases — classified as an ordinary role. The node
|
||||||
/// then resolved a <see langword="null"/> ClusterId while still gossiping a cluster-scoped role,
|
/// then resolved a <see langword="null"/> ClusterId while still gossiping a cluster-scoped role,
|
||||||
@@ -33,6 +33,7 @@ public static class RoleParser
|
|||||||
/// <c>ClusterRoleInfo</c> did not, and got the null.
|
/// <c>ClusterRoleInfo</c> did not, and got the null.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="role">The role name to test.</param>
|
/// <param name="role">The role name to test.</param>
|
||||||
|
/// <returns><see langword="true"/> if the role is a cluster-scoped role.</returns>
|
||||||
public static bool IsClusterRole(string role) =>
|
public static bool IsClusterRole(string role) =>
|
||||||
role is not null
|
role is not null
|
||||||
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
|
&& role.StartsWith(ClusterRolePrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -47,9 +48,10 @@ public static class RoleParser
|
|||||||
/// <c>OTOPCUA_ROLES</c> path (⇒ <c>site-a</c>) — and normalizing one side would silently rename
|
/// <c>OTOPCUA_ROLES</c> path (⇒ <c>site-a</c>) — and normalizing one side would silently rename
|
||||||
/// the Akka role of a running deployment (docker-dev's rig configures <c>cluster-MAIN</c> /
|
/// the Akka role of a running deployment (docker-dev's rig configures <c>cluster-MAIN</c> /
|
||||||
/// <c>cluster-SITE-A</c> verbatim today). Every consumer of a ClusterId therefore compares it
|
/// <c>cluster-SITE-A</c> verbatim today). Every consumer of a ClusterId therefore compares it
|
||||||
/// case-insensitively; see the identity rule on <c>AclScopeRef</c> (Gitea #533).
|
/// case-insensitively; see the identity rule on <c>AclScopeRef</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="role">The cluster-scoped role name.</param>
|
/// <param name="role">The cluster-scoped role name.</param>
|
||||||
|
/// <returns>The ClusterId portion of the role, with the role prefix removed.</returns>
|
||||||
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
|
public static string ClusterIdFromRole(string role) => role[ClusterRolePrefix.Length..];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,7 +74,7 @@ public static class RoleParser
|
|||||||
/// cluster-scoped role (a legacy / single-mesh node).
|
/// cluster-scoped role (a legacy / single-mesh node).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The single home for a derivation that was open-coded at four call sites before Gitea #532
|
/// The single home for a derivation that was open-coded at four call sites before
|
||||||
/// (<c>ClusterRoleInfo</c>, the ControlPlane singleton scope, <c>SplitTopologyTransportValidator</c>
|
/// (<c>ClusterRoleInfo</c>, the ControlPlane singleton scope, <c>SplitTopologyTransportValidator</c>
|
||||||
/// and <c>ConfigDbApiKeyStore</c>) — each with its own view of whether the classification was
|
/// and <c>ConfigDbApiKeyStore</c>) — each with its own view of whether the classification was
|
||||||
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
|
/// case-sensitive, which is how the mixed-case gap above survived in three of the four.
|
||||||
@@ -91,7 +93,7 @@ public static class RoleParser
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ⚠️ <b>This fallback was documented for a year before it existed</b> (Gitea #532).
|
/// ⚠️ <b>This fallback was documented for a year before it existed</b>.
|
||||||
/// <c>AkkaClusterOptions.Roles</c>' own XML doc and <see cref="SplitTopologyTransportValidator"/>
|
/// <c>AkkaClusterOptions.Roles</c>' own XML doc and <see cref="SplitTopologyTransportValidator"/>
|
||||||
/// both described it; only the validator implemented it, privately. A node configured solely
|
/// both described it; only the validator implemented it, privately. A node configured solely
|
||||||
/// through <c>OTOPCUA_ROLES</c> — which drives every bit of <c>Program.cs</c>'s conditional
|
/// through <c>OTOPCUA_ROLES</c> — which drives every bit of <c>Program.cs</c>'s conditional
|
||||||
@@ -114,7 +116,7 @@ public static class RoleParser
|
|||||||
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
|
/// <exception cref="ArgumentException">Either source names an unknown role.</exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Thin delegation to <see cref="EffectiveRoles.Resolve(IReadOnlyList{string}?, string?)"/> —
|
/// Thin delegation to <see cref="EffectiveRoles.Resolve(IReadOnlyList{string}?, string?)"/> —
|
||||||
/// the single role authority (archreview R3-04). <see cref="EffectiveRoles"/> is the type to
|
/// the single role authority. <see cref="EffectiveRoles"/> is the type to
|
||||||
/// reach for directly; this overload is retained so existing callers of the array-returning
|
/// reach for directly; this overload is retained so existing callers of the array-returning
|
||||||
/// shape keep compiling.
|
/// shape keep compiling.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
|
services.AddValidatedOptions<AkkaClusterOptions, AkkaClusterOptionsValidator>(
|
||||||
configuration, AkkaClusterOptions.SectionName);
|
configuration, AkkaClusterOptions.SectionName);
|
||||||
|
|
||||||
// R3-04 (structural identity, choice 3): EffectiveRoles is resolved ONCE and registered as a
|
// Structural identity: EffectiveRoles is resolved ONCE and registered as a
|
||||||
// singleton, off the SAME IConfiguration this call received — not off a DI-resolved
|
// singleton, off the SAME IConfiguration this call received — not off a DI-resolved
|
||||||
// IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
|
// IConfiguration, which a plain ServiceCollection (as opposed to a WebApplicationBuilder) never
|
||||||
// registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
|
// registers. Every consumer (Program.cs's hasAdmin/hasDriver, the four role-scoped validators,
|
||||||
@@ -54,7 +54,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// otherwise; TryAddSingleton is a no-op on a real host, where it is already registered.
|
// otherwise; TryAddSingleton is a no-op on a real host, where it is already registered.
|
||||||
services.TryAddSingleton(configuration);
|
services.TryAddSingleton(configuration);
|
||||||
|
|
||||||
// Gitea #532 (superseded by R3-04): apply the OTOPCUA_ROLES fallback the Roles property has
|
// Apply the OTOPCUA_ROLES fallback the Roles property has
|
||||||
// always DOCUMENTED, in the one place every consumer reads through. Before this, a node
|
// always DOCUMENTED, in the one place every consumer reads through. Before this, a node
|
||||||
// configured only through the env var — the knob that drives all of Program.cs's role wiring —
|
// configured only through the env var — the knob that drives all of Program.cs's role wiring —
|
||||||
// bound an EMPTY Roles array and so joined the cluster role-less: invisible to
|
// bound an EMPTY Roles array and so joined the cluster role-less: invisible to
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
|||||||
/// Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers
|
/// Phase 2/3/5 dark switches keep their DPS branches alive, but DPS is mesh-wide — it delivers
|
||||||
/// nothing between a central node and a site node that no longer share a gossip ring. The
|
/// nothing between a central node and a site node that no longer share a gossip ring. The
|
||||||
/// marker of the split topology is a <c>cluster-{ClusterId}</c> role
|
/// marker of the split topology is a <c>cluster-{ClusterId}</c> role
|
||||||
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only post-Phase-6. A node that
|
/// (<see cref="RoleParser.IsClusterRole"/>); those roles exist only in the per-cluster mesh topology. A node that
|
||||||
/// carries one <b>must</b> use the mesh-crossing transports, and this validator refuses to
|
/// carries one <b>must</b> use the mesh-crossing transports, and this validator refuses to
|
||||||
/// boot it otherwise — the alternative is a node that runs, listens, and silently delivers no
|
/// boot it otherwise — the alternative is a node that runs, listens, and silently delivers no
|
||||||
/// commands or telemetry, which has no stack trace and no failing node to point at.
|
/// commands or telemetry, which has no stack trace and no failing node to point at.
|
||||||
@@ -44,16 +44,21 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
|
|||||||
/// transport-mode cross-reads (<c>MeshTransport:Mode</c>, <c>Telemetry:Mode</c>,
|
/// transport-mode cross-reads (<c>MeshTransport:Mode</c>, <c>Telemetry:Mode</c>,
|
||||||
/// <c>TelemetryDial:Mode</c>) — not <c>IClusterRoleInfo</c>, whose implementation needs the
|
/// <c>TelemetryDial:Mode</c>) — not <c>IClusterRoleInfo</c>, whose implementation needs the
|
||||||
/// <c>ActorSystem</c>, which does not exist yet at <c>ValidateOnStart</c> time.
|
/// <c>ActorSystem</c>, which does not exist yet at <c>ValidateOnStart</c> time.
|
||||||
/// <see cref="EffectiveRoles"/> is the single role authority (archreview R3-04) — this rule no
|
/// <see cref="EffectiveRoles"/> is the single role authority — this rule no
|
||||||
/// longer re-derives the effective, normalized role list itself.
|
/// longer re-derives the effective, normalized role list itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="configuration">The host configuration used to cross-read the transport-mode settings.</param>
|
||||||
|
/// <param name="effectiveRoles">The node's effective, normalized role list.</param>
|
||||||
public SplitTopologyTransportValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
|
public SplitTopologyTransportValidator(IConfiguration configuration, EffectiveRoles effectiveRoles)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_effectiveRoles = effectiveRoles;
|
_effectiveRoles = effectiveRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Fails startup when a split-topology node is left on a transport mode that cannot work there.</summary>
|
||||||
|
/// <param name="name">The options instance name; unused.</param>
|
||||||
|
/// <param name="options">The mesh transport options; unused because every input is cross-read from configuration.</param>
|
||||||
|
/// <returns>A success result, or a failure naming the offending transport setting.</returns>
|
||||||
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
|
||||||
{
|
{
|
||||||
// The options instance is intentionally unused: every value this rule depends on is cross-read
|
// The options instance is intentionally unused: every value this rule depends on is cross-read
|
||||||
|
|||||||
@@ -93,14 +93,18 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
|||||||
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
|
||||||
/// constructor dependency is safe here. This validator's only use of configuration is this
|
/// constructor dependency is safe here. This validator's only use of configuration is this
|
||||||
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
/// node's roles, so it takes <see cref="EffectiveRoles"/> — the single role authority
|
||||||
/// (archreview R3-04) — directly rather than <see cref="IConfiguration"/>.
|
/// — directly rather than <see cref="IConfiguration"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="effectiveRoles">The node's resolved roles, the single role authority.</param>
|
||||||
public TelemetryOptionsValidator(EffectiveRoles effectiveRoles)
|
public TelemetryOptionsValidator(EffectiveRoles effectiveRoles)
|
||||||
{
|
{
|
||||||
_effectiveRoles = effectiveRoles;
|
_effectiveRoles = effectiveRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Validates the telemetry options against this node's roles.</summary>
|
||||||
|
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
|
||||||
|
/// <param name="options">The options to validate.</param>
|
||||||
|
/// <returns>Success, or a failure listing every invalid setting.</returns>
|
||||||
public ValidateOptionsResult Validate(string? name, TelemetryOptions options)
|
public ValidateOptionsResult Validate(string? name, TelemetryOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -117,8 +121,8 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
|||||||
+ $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'.");
|
+ $"Expected '{TelemetryOptions.ModeDps}' or '{TelemetryOptions.ModeGrpc}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveRoles is the single role authority (archreview R3-04) — its precedence already
|
// EffectiveRoles is the single role authority — its precedence already
|
||||||
// applies the OTOPCUA_ROLES fallback (Gitea #532) and its normalization already folds case, so
|
// applies the OTOPCUA_ROLES fallback and its normalization already folds case, so
|
||||||
// a node configured only through the env var, or spelled 'Driver', still satisfies the
|
// a node configured only through the env var, or spelled 'Driver', still satisfies the
|
||||||
// Grpc-mode requirements below.
|
// Grpc-mode requirements below.
|
||||||
var isDriver = _effectiveRoles.HasDriver;
|
var isDriver = _effectiveRoles.HasDriver;
|
||||||
@@ -157,7 +161,10 @@ public sealed class TelemetryOptionsValidator : IValidateOptions<TelemetryOption
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TelemetryDialOptionsValidator : IValidateOptions<TelemetryDialOptions>
|
public sealed class TelemetryDialOptionsValidator : IValidateOptions<TelemetryDialOptions>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Validates the telemetry dial options.</summary>
|
||||||
|
/// <param name="name">The named options instance, or <see langword="null"/> for the default.</param>
|
||||||
|
/// <param name="options">The options to validate.</param>
|
||||||
|
/// <returns>Success, or a failure listing every invalid setting.</returns>
|
||||||
public ValidateOptionsResult Validate(string? name, TelemetryDialOptions options)
|
public ValidateOptionsResult Validate(string? name, TelemetryDialOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
|||||||
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
|
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Drops this session's reference to the captured tree; the tree itself may be shared.</summary>
|
||||||
|
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
|
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
|
||||||
@@ -68,7 +69,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Surfaces a leaf's driver-native type name as the <c>dataType</c> address field, so a
|
/// Surfaces a leaf's driver-native type name as the <c>dataType</c> address field, so a
|
||||||
/// browse-commit can stamp the driver-typed <c>TagConfig</c> with the type the controller
|
/// browse-commit can stamp the driver-typed <c>TagConfig</c> with the type the controller
|
||||||
/// actually reported (Gitea #536).
|
/// actually reported.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="a">The captured attribute.</param>
|
/// <param name="a">The captured attribute.</param>
|
||||||
/// <returns>The address fields, or null when the driver reported no native type.</returns>
|
/// <returns>The address fields, or null when the driver reported no native type.</returns>
|
||||||
@@ -80,7 +81,7 @@ public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSessio
|
|||||||
/// reverse map would bind a 16-bit INT as a 32-bit DINT and read four bytes for two.
|
/// reverse map would bind a 16-bit INT as a 32-bit DINT and read four bytes for two.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Null when the driver stamped nothing — the mapper then leaves the model's own default in
|
/// Null when the driver stamped nothing — the mapper then leaves the model's own default in
|
||||||
/// place, which is exactly the pre-#536 behaviour rather than a guess.
|
/// place, which is exactly the previous behaviour rather than a guess.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static IReadOnlyDictionary<string, string>? NativeTypeFields(DriverAttributeInfo a)
|
private static IReadOnlyDictionary<string, string>? NativeTypeFields(DriverAttributeInfo a)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
|||||||
public bool Truncated;
|
public bool Truncated;
|
||||||
public readonly int NodeCap = nodeCap;
|
public readonly int NodeCap = nodeCap;
|
||||||
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
|
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
|
||||||
|
/// <summary>Reserves a slot for one more node, marking the capture truncated when the cap is reached.</summary>
|
||||||
|
/// <returns><see langword="true"/> when the node may be recorded; <see langword="false"/> when the cap is reached.</returns>
|
||||||
public bool TryReserve()
|
public bool TryReserve()
|
||||||
{
|
{
|
||||||
if (Count >= NodeCap) { Truncated = true; return false; }
|
if (Count >= NodeCap) { Truncated = true; return false; }
|
||||||
@@ -96,7 +98,10 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
|||||||
|
|
||||||
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
|
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public string FullReference => node.Id;
|
public string FullReference => node.Id;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||||
{
|
{
|
||||||
node.IsAlarmMarked = true;
|
node.IsAlarmMarked = true;
|
||||||
@@ -107,6 +112,7 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
|||||||
private sealed class NoopAlarmSink : IAlarmConditionSink
|
private sealed class NoopAlarmSink : IAlarmConditionSink
|
||||||
{
|
{
|
||||||
public static readonly NoopAlarmSink Instance = new();
|
public static readonly NoopAlarmSink Instance = new();
|
||||||
|
/// <inheritdoc />
|
||||||
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
|
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
|
/// 20 s per-call browse timeout, which only covers serving from memory.</summary>
|
||||||
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
|
public static readonly TimeSpan OpenTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
/// <summary>Bound on the cleanup ShutdownAsync (R2-01: no unbounded waits — a driver wedged
|
/// <summary>Bound on the cleanup ShutdownAsync (no unbounded waits — a driver wedged
|
||||||
/// in discovery may be equally wedged in shutdown).</summary>
|
/// in discovery may be equally wedged in shutdown).</summary>
|
||||||
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
|
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
|
public DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger)
|
||||||
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
|
: this(driverFactory, logger, OpenTimeout, ShutdownTimeout) { }
|
||||||
|
|
||||||
// Test seam: short timeouts.
|
/// <summary>Test seam: constructor with explicit open and shutdown bounds.</summary>
|
||||||
|
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
||||||
|
/// <param name="logger">Logger for shutdown-timeout warnings.</param>
|
||||||
|
/// <param name="openTimeout">Upper bound on opening a capture driver and running discovery.</param>
|
||||||
|
/// <param name="shutdownTimeout">Upper bound on the cleanup shutdown of a capture driver.</param>
|
||||||
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
|
internal DiscoveryDriverBrowser(IDriverFactory driverFactory, ILogger<DiscoveryDriverBrowser> logger,
|
||||||
TimeSpan openTimeout, TimeSpan shutdownTimeout)
|
TimeSpan openTimeout, TimeSpan shutdownTimeout)
|
||||||
{
|
{
|
||||||
@@ -111,6 +115,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the in-flight de-duplication key for a capture request.</summary>
|
||||||
|
/// <param name="driverType">The driver type name.</param>
|
||||||
|
/// <param name="configJson">The driver configuration JSON.</param>
|
||||||
|
/// <returns>A key combining the lower-cased driver type and a hash of the configuration.</returns>
|
||||||
internal static string CaptureKey(string driverType, string configJson) =>
|
internal static string CaptureKey(string driverType, string configJson) =>
|
||||||
driverType.ToLowerInvariant() + ":" +
|
driverType.ToLowerInvariant() + ":" +
|
||||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||||
@@ -195,6 +203,10 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies the driver-specific browse-time patch (if any) to the configuration JSON.</summary>
|
||||||
|
/// <param name="driverType">The driver type name.</param>
|
||||||
|
/// <param name="configJson">The driver configuration JSON.</param>
|
||||||
|
/// <returns>The patched configuration JSON, or the input unchanged when the driver type has no patch.</returns>
|
||||||
internal static string PatchForBrowse(string driverType, string configJson)
|
internal static string PatchForBrowse(string driverType, string configJson)
|
||||||
{
|
{
|
||||||
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
|
if (!BrowsePatches.TryGetValue(driverType, out var patch)) return configJson;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public enum ApplyAckOutcome { Applied, Failed }
|
|||||||
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
|
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
|
||||||
/// <param name="CorrelationId">The dispatch's correlation id.</param>
|
/// <param name="CorrelationId">The dispatch's correlation id.</param>
|
||||||
/// <param name="Degraded">
|
/// <param name="Degraded">
|
||||||
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
|
/// <see langword="true"/> when the node applied the deployment (the revision advanced) but
|
||||||
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
|
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
|
||||||
/// outcome reported in time — so the served address space may be partial. Only meaningful with
|
/// outcome reported in time — so the served address space may be partial. Only meaningful with
|
||||||
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
|
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
|
||||||
@@ -30,7 +30,7 @@ public sealed record ApplyAck(
|
|||||||
string? DegradedReason = null)
|
string? DegradedReason = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
|
/// The prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
|
||||||
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
|
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
|
||||||
/// a ConfigDb (which writes its own row), so both spell the row identically.
|
/// a ConfigDb (which writes its own row), so both spell the row identically.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ public static class DeployTimings
|
|||||||
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
|
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
|
||||||
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
|
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
|
/// <summary>How far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
|
||||||
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
|
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
|
||||||
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
|
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
|
/// <summary>The node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
|
||||||
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
|
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
|
||||||
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
|
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
|
||||||
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
|
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
|||||||
/// so a driver-only node has no ConfigDb connection to write rows to. This channel already reaches
|
/// so a driver-only node has no ConfigDb connection to write rows to. This channel already reaches
|
||||||
/// <c>/hosts</c>, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already
|
/// <c>/hosts</c>, already survives the mesh split via the Phase 5 gRPC telemetry stream, and already
|
||||||
/// replays a last-value snapshot on every (re)subscribe — so per-host state re-primes after a
|
/// replays a last-value snapshot on every (re)subscribe — so per-host state re-primes after a
|
||||||
/// reconnect without a durable store. The table was dropped; see Gitea #521.</para>
|
/// reconnect without a durable store. The table was dropped.</para>
|
||||||
/// <para><b>Value-equality caveat.</b> A record's generated <c>Equals</c> compares this list by
|
/// <para><b>Value-equality caveat.</b> A record's generated <c>Equals</c> compares this list by
|
||||||
/// REFERENCE, so two <c>DriverHealthChanged</c> carrying equal-but-distinct lists are not equal.
|
/// REFERENCE, so two <c>DriverHealthChanged</c> carrying equal-but-distinct lists are not equal.
|
||||||
/// Nothing dedups on record equality — <c>DriverInstanceActor</c> keeps its own flattened fingerprint
|
/// Nothing dedups on record equality — <c>DriverInstanceActor</c> keeps its own flattened fingerprint
|
||||||
@@ -48,13 +48,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
|||||||
/// What this driver skipped when it last built its tag table, from
|
/// What this driver skipped when it last built its tag table, from
|
||||||
/// <c>ISkippedTagReporter.GetSkippedTags()</c>; null when the driver does not implement that
|
/// <c>ISkippedTagReporter.GetSkippedTags()</c>; null when the driver does not implement that
|
||||||
/// capability. All twelve production drivers do.
|
/// capability. All twelve production drivers do.
|
||||||
/// <para><b>Why this is on the health snapshot (Gitea #541).</b> A skip happens at init, is not an
|
/// <para><b>Why this is on the health snapshot.</b> A skip happens at init, is not an
|
||||||
/// error, and does not stop the driver's OTHER tags from reading — so <c>State</c> stays Healthy,
|
/// error, and does not stop the driver's OTHER tags from reading — so <c>State</c> stays Healthy,
|
||||||
/// <c>ErrorCount5Min</c> stays 0, <c>LastError</c> stays null and <c>LastSuccessfulReadUtc</c> keeps
|
/// <c>ErrorCount5Min</c> stays 0, <c>LastError</c> stays null and <c>LastSuccessfulReadUtc</c> keeps
|
||||||
/// advancing while the driver ignores part or all of its configuration. On the Makino in Gitea #540
|
/// advancing while the driver ignores part or all of its configuration. On the Makino
|
||||||
/// that combination held while 20 of 22 tags were ignored. This field is what contradicts it.</para>
|
/// that combination held while 20 of 22 tags were ignored. This field is what contradicts it.</para>
|
||||||
/// <para><b>Additive, never a state change.</b> See <c>ISkippedTagReporter</c> for why a skipped tag
|
/// <para><b>Additive, never a state change.</b> See <c>ISkippedTagReporter</c> for why a skipped tag
|
||||||
/// must not latch the driver non-Healthy — it would satisfy both halves of the #527 comms-loss
|
/// must not latch the driver non-Healthy — it would satisfy both halves of the comms-loss
|
||||||
/// conjunction and reconnect-loop a healthy device.</para>
|
/// conjunction and reconnect-loop a healthy device.</para>
|
||||||
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
|
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
|
||||||
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
|
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
|
||||||
@@ -62,7 +62,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
|||||||
/// <param name="Diagnostics">
|
/// <param name="Diagnostics">
|
||||||
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
|
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
|
||||||
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
|
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
|
||||||
/// capability (Gitea #640). Additive, never a state change. Same by-reference value-equality caveat as
|
/// capability. Additive, never a state change. Same by-reference value-equality caveat as
|
||||||
/// <paramref name="HostStatuses"/>.
|
/// <paramref name="HostStatuses"/>.
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record DriverHealthChanged(
|
public sealed record DriverHealthChanged(
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ public static class MeshTransportDtoMapper
|
|||||||
if (message.FailureReason is not null)
|
if (message.FailureReason is not null)
|
||||||
proto.FailureReason = message.FailureReason;
|
proto.FailureReason = message.FailureReason;
|
||||||
|
|
||||||
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
|
// Additive fields; a clean ack leaves both unset, byte-identical to an encode that predates these fields.
|
||||||
proto.Degraded = message.Degraded;
|
proto.Degraded = message.Degraded;
|
||||||
if (message.DegradedReason is not null)
|
if (message.DegradedReason is not null)
|
||||||
proto.DegradedReason = message.DegradedReason;
|
proto.DegradedReason = message.DegradedReason;
|
||||||
@@ -255,7 +255,7 @@ public static class MeshTransportDtoMapper
|
|||||||
outcome,
|
outcome,
|
||||||
proto.HasFailureReason ? proto.FailureReason : null,
|
proto.HasFailureReason ? proto.FailureReason : null,
|
||||||
CorrelationId.Parse(proto.CorrelationId),
|
CorrelationId.Parse(proto.CorrelationId),
|
||||||
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
|
// Absent on an old node's ack, which decodes as false/null: today's clean ack.
|
||||||
Degraded: proto.Degraded,
|
Degraded: proto.Degraded,
|
||||||
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
|
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public static class OtOpcUaTelemetry
|
|||||||
public const string PollOverrunDriverInstanceTag = "driver_instance";
|
public const string PollOverrunDriverInstanceTag = "driver_instance";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
|
/// Session/publish-level events of an OPC UA Client driver instance: the operator's view of a
|
||||||
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
|
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
|
||||||
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
|
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
|
||||||
/// keepalive_miss|keepalive_recovered|stale_notification_dropped</c>.
|
/// keepalive_miss|keepalive_recovered|stale_notification_dropped</c>.
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|||||||
/// <param name="StatusCode">
|
/// <param name="StatusCode">
|
||||||
/// Status code of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
/// Status code of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
||||||
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
||||||
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
/// default. It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||||
/// <see cref="OpcUaStatus.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
/// <see cref="OpcUaStatus.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
||||||
/// Carries the full sub-code since #519, so a comms-lost condition reports
|
/// Carries the full sub-code, so a comms-lost condition reports
|
||||||
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
|
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="UnshelveAtUtc">
|
/// <param name="UnshelveAtUtc">
|
||||||
/// For <see cref="AlarmShelvingKind.Timed"/>, the UTC instant the engine expires the shelve; otherwise
|
/// For <see cref="AlarmShelvingKind.Timed"/>, the UTC instant the engine expires the shelve; otherwise
|
||||||
/// <c>null</c>. Carried so the node's Part 9 <c>ShelvingState.UnshelveTime</c> reports the real remaining
|
/// <c>null</c>. Carried so the node's Part 9 <c>ShelvingState.UnshelveTime</c> reports the real remaining
|
||||||
/// time instead of the SDK's ~24.8-day ceiling (Gitea #597). A timed snapshot without it (a caller that
|
/// time instead of the SDK's ~24.8-day ceiling. A timed snapshot without it (a caller that
|
||||||
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
|
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record AlarmConditionSnapshot(
|
public sealed record AlarmConditionSnapshot(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
|
=> _inner.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
// Forward the multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||||
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||||
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
@@ -100,7 +101,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||||
|
|
||||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
// Surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||||
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
|
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
|
||||||
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
|
/// Late-binding adapter that accepts and retains the latest requested ServiceLevel until
|
||||||
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
|
/// the SDK publisher is available. Binding synchronously replays that request, including zero.
|
||||||
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
|
|
||||||
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
|
|
||||||
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Publishing and binding are serialized through the synchronous inner call. Inner publishers
|
||||||
|
/// must not call back into this adapter. Acceptance while unbound is not an SDK write confirmation.
|
||||||
|
/// A failed call retains both the requested level and the installed publisher for later recovery.
|
||||||
|
/// </remarks>
|
||||||
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||||
{
|
{
|
||||||
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
|
private readonly object _gate = new();
|
||||||
|
private IServiceLevelPublisher? _inner;
|
||||||
|
private byte? _latest;
|
||||||
|
|
||||||
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
|
/// <summary>Bind and replay the latest request, or unbind while retaining that request.</summary>
|
||||||
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
|
/// <param name="inner">The publisher implementation to use, or null to unbind.</param>
|
||||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
public void SetInner(IServiceLevelPublisher? inner)
|
||||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
if (_latest is { } latest) _inner?.Publish(latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
public void Publish(byte serviceLevel)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_latest = serviceLevel;
|
||||||
|
_inner?.Publish(serviceLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <summary>Write a Variable node's current value + status code + source timestamp.</summary>
|
/// <summary>Write a Variable node's current value + status code + source timestamp.</summary>
|
||||||
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
|
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
|
||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim
|
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim, so
|
||||||
/// (#519): a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
|
/// a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
|
||||||
/// <c>BadNoCommunication</c>, not as a generic <see cref="OpcUaStatus.Bad"/>. Use
|
/// <c>BadNoCommunication</c>, not as a generic <see cref="OpcUaStatus.Bad"/>. Use
|
||||||
/// <see cref="OpcUaStatus"/>' named constants rather than a literal.</param>
|
/// <see cref="OpcUaStatus"/>' named constants rather than a literal.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||||
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
||||||
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
|
/// resolves the namespace index from the realm rather than parsing it out of the id string. The fan-out
|
||||||
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
||||||
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// the condition was materialised under).</param>
|
/// the condition was materialised under).</param>
|
||||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
/// <summary>Annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||||
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
|
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
|
||||||
/// restored → <see cref="OpcUaStatus.Good"/>). Sets ONLY the condition's Quality — never
|
/// restored → <see cref="OpcUaStatus.Good"/>). Sets ONLY the condition's Quality — never
|
||||||
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
||||||
/// only on a change to the status code itself — since #519 that includes a move between two Bad
|
/// only on a change to the status code itself — that now includes a move between two Bad
|
||||||
/// sub-codes, which genuinely changes what a subscriber reads. A no-op for an unmaterialised /
|
/// sub-codes, which genuinely changes what a subscriber reads. A no-op for an unmaterialised /
|
||||||
/// non-condition node.</summary>
|
/// non-condition node.</summary>
|
||||||
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||||
@@ -172,9 +172,9 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
||||||
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
||||||
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
||||||
/// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
|
/// <param name="refuseArrayOnScalar">When true and the node is SCALAR, a client write
|
||||||
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
|
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
|
||||||
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
|
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the earlier behaviour — the
|
||||||
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
|
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
|
||||||
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
|
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
|
||||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
|
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
|
||||||
@@ -186,7 +186,7 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
void RebuildAddressSpace();
|
void RebuildAddressSpace();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
/// Mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
||||||
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
|
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
|
||||||
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
|
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
|
||||||
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
|
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
|
||||||
@@ -229,7 +229,7 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
|
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
|
||||||
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (#519: status verbatim).</summary>
|
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (status verbatim).</summary>
|
||||||
public readonly record struct ValueWrite(
|
public readonly record struct ValueWrite(
|
||||||
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ public readonly record struct ValueWrite(
|
|||||||
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
|
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
|
||||||
/// <paramref name="SourceTimestampUtc"/> change.</summary>
|
/// <paramref name="SourceTimestampUtc"/> change.</summary>
|
||||||
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
|
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
|
||||||
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
|
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim.</param>
|
||||||
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
|
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
|
||||||
public readonly record struct StatusWrite(
|
public readonly record struct StatusWrite(
|
||||||
@@ -279,6 +279,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
/// <inheritdoc />
|
||||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity
|
/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity
|
||||||
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum (Gitea #519).
|
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum.
|
||||||
///
|
///
|
||||||
/// <para><b>Why these are bare <c>uint</c> literals.</b> Commons and Runtime deliberately do not
|
/// <para><b>Why these are bare <c>uint</c> literals.</b> Commons and Runtime deliberately do not
|
||||||
/// reference the OPC UA SDK — <see cref="IOpcUaAddressSpaceSink"/> exists precisely so the Runtime
|
/// reference the OPC UA SDK — <see cref="IOpcUaAddressSpaceSink"/> exists precisely so the Runtime
|
||||||
@@ -10,11 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|||||||
/// decision the driver assemblies make, and it carries the same single failure mode: nothing checks
|
/// decision the driver assemblies make, and it carries the same single failure mode: nothing checks
|
||||||
/// the literal against the name. <c>StatusCodeParityTests</c> closes it by reflection, and it can
|
/// the literal against the name. <c>StatusCodeParityTests</c> closes it by reflection, and it can
|
||||||
/// only see a <b>named</b> constant — so add codes here rather than writing a literal at a call
|
/// only see a <b>named</b> constant — so add codes here rather than writing a literal at a call
|
||||||
/// site, exactly as Gitea #497 concluded for the drivers.</para>
|
/// site, exactly as for the drivers.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Severity, not equality, is what callers should branch on.</b> A status code's top two
|
/// <para><b>Severity, not equality, is what callers should branch on.</b> A status code's top two
|
||||||
/// bits carry its Part 4 severity (00 = Good, 01 = Uncertain, 10/11 = Bad); everything below is the
|
/// bits carry its Part 4 severity (00 = Good, 01 = Uncertain, 10/11 = Bad); everything below is the
|
||||||
/// sub-code. Before #519 the engine collapsed every value onto a 3-state enum here and the node
|
/// sub-code. Previously the engine collapsed every value onto a 3-state enum here and the node
|
||||||
/// manager re-expanded <c>Bad</c> to a bare <c>0x80000000</c>, so no driver's sub-code ever reached
|
/// manager re-expanded <c>Bad</c> to a bare <c>0x80000000</c>, so no driver's sub-code ever reached
|
||||||
/// a client. The sub-code now rides through to the SDK verbatim, and any code that only needs the
|
/// a client. The sub-code now rides through to the SDK verbatim, and any code that only needs the
|
||||||
/// bucket uses <see cref="IsGood"/> / <see cref="IsUncertain"/> / <see cref="IsBad"/> rather than
|
/// bucket uses <see cref="IsGood"/> / <see cref="IsUncertain"/> / <see cref="IsBad"/> rather than
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
|
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine, so a
|
||||||
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
|
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
|
||||||
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
|
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|||||||
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
|
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
|
||||||
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
|
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
|
||||||
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
|
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
|
||||||
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
|
/// it. This is an operational sharp edge, not an oversight.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ApiKey
|
public sealed class ApiKey
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
/// <summary>Gets the DbSet of node ACLs.</summary>
|
/// <summary>Gets the DbSet of node ACLs.</summary>
|
||||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||||
|
|
||||||
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
|
/// <summary>OPC UA data-plane API keys. One principal per key; secret stored as a peppered hash.</summary>
|
||||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||||
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
||||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||||
@@ -563,14 +563,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureDriverHostStatus is GONE (Gitea #521). The DriverHostStatus table held per-host
|
// ConfigureDriverHostStatus is GONE. The DriverHostStatus table held per-host
|
||||||
// connectivity that a publisher hosted service was supposed to upsert from each driver node. That
|
// connectivity that a publisher hosted service was supposed to upsert from each driver node. That
|
||||||
// publisher was never written, and per-cluster mesh Phase 4 made it unwritable as designed: ConfigDb
|
// publisher was never written, and per-cluster mesh Phase 4 made it unwritable as designed: ConfigDb
|
||||||
// is registered only on the admin role, so a driver-only node has no connection to write rows with.
|
// is registered only on the admin role, so a driver-only node has no connection to write rows with.
|
||||||
// Per-host connectivity now rides DriverHealthChanged.HostStatuses to /hosts over the Phase 5
|
// Per-host connectivity now rides DriverHealthChanged.HostStatuses to /hosts over the Phase 5
|
||||||
// telemetry stream, which needs no DB and survives the mesh split.
|
// telemetry stream, which needs no DB and survives the mesh split.
|
||||||
//
|
//
|
||||||
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason (Gitea #524). That table held
|
// ConfigureDriverInstanceResilienceStatus is GONE for the same reason. That table held
|
||||||
// per-(instance, host) breaker/in-flight counters and was equally never written: its doc described a
|
// per-(instance, host) breaker/in-flight counters and was equally never written: its doc described a
|
||||||
// HostedService sampler and an AdminUI left-join on DriverHostStatus, neither of which was built. The
|
// HostedService sampler and an AdminUI left-join on DriverHostStatus, neither of which was built. The
|
||||||
// live data reaches the AdminUI's DriverStatusPanel as DriverResilienceStatusChanged over the
|
// live data reaches the AdminUI's DriverStatusPanel as DriverResilienceStatusChanged over the
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ public sealed class DraftSnapshot
|
|||||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
||||||
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
/// the Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||||
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
||||||
/// <summary>Gets the list of poll groups.</summary>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public static class DraftValidator
|
|||||||
ValidateReservationPreflight(draft, errors);
|
ValidateReservationPreflight(draft, errors);
|
||||||
ValidateEquipmentIdDerivation(draft, errors);
|
ValidateEquipmentIdDerivation(draft, errors);
|
||||||
|
|
||||||
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
// v3 rules. The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||||
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
||||||
ValidateRawNameCharset(draft, errors);
|
ValidateRawNameCharset(draft, errors);
|
||||||
ValidateHistorizedTagnameLength(draft, errors);
|
ValidateHistorizedTagnameLength(draft, errors);
|
||||||
@@ -47,7 +47,7 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #520 stage 3: every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
/// every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
||||||
/// space actually contains.
|
/// space actually contains.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -74,7 +74,7 @@ public static class DraftValidator
|
|||||||
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
|
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
|
||||||
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
|
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
|
||||||
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
|
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
|
||||||
/// ordinal until Gitea #533; the cross-cluster comparison below was ordinal too, which was the
|
/// ordinal until that was fixed; the cross-cluster comparison below was ordinal too, which was the
|
||||||
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
|
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
|
||||||
/// would have matched, and the two spellings genuinely occur (see
|
/// would have matched, and the two spellings genuinely occur (see
|
||||||
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
|
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
|
||||||
@@ -279,7 +279,7 @@ public static class DraftValidator
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||||
/// at the deploy gate (Gitea #498).
|
/// at the deploy gate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
||||||
@@ -294,7 +294,7 @@ public static class DraftValidator
|
|||||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||||
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
||||||
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
||||||
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
|
/// <see cref="DraftSnapshot"/> and is gated at its save instead; see
|
||||||
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
||||||
/// never enters the artifact.</para>
|
/// never enters the artifact.</para>
|
||||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||||
@@ -344,7 +344,7 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
|
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device.</summary>
|
||||||
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
|
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -352,6 +352,7 @@ public static class DraftValidator
|
|||||||
/// use, so the two can never describe different rules.
|
/// use, so the two can never describe different rules.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="driverLabel">The driver instance id or name to cite.</param>
|
/// <param name="driverLabel">The driver instance id or name to cite.</param>
|
||||||
|
/// <returns>The validation message text.</returns>
|
||||||
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
|
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
|
||||||
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
|
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
|
||||||
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
|
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
|
||||||
@@ -359,7 +360,7 @@ public static class DraftValidator
|
|||||||
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
|
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
/// An MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
||||||
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
|
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
|
||||||
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
|
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
|
||||||
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
|
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
|
||||||
@@ -380,7 +381,7 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
/// <summary>Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||||
/// a <see cref="Script"/> row in the draft.</item>
|
/// a <see cref="Script"/> row in the draft.</item>
|
||||||
@@ -634,7 +635,7 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rule itself lives on UnsSegment, which the AdminUI's authoring guards also call — a second
|
// The rule itself lives on UnsSegment, which the AdminUI's authoring guards also call — a second
|
||||||
// copy here is exactly how the two drifted apart (Gitea #535). Only the per-level wording is local.
|
// copy here is exactly how the two drifted apart. Only the per-level wording is local.
|
||||||
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
{
|
{
|
||||||
foreach (var a in draft.UnsAreas)
|
foreach (var a in draft.UnsAreas)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The one place that decides whether a persisted config blob carries a literal Sql
|
/// The one place that decides whether a persisted config blob carries a literal Sql
|
||||||
/// <c>connectionString</c> (Gitea #498 / #499).
|
/// <c>connectionString</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
|||||||
/// rule is not cosmetic: it is what keeps a NodeId parseable and stable.
|
/// rule is not cosmetic: it is what keeps a NodeId parseable and stable.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ⚠️ <b>This exists because the rule was duplicated and drifted (Gitea #535).</b> The deploy gate
|
/// ⚠️ <b>This exists because the rule was duplicated and drifted.</b> The deploy gate
|
||||||
/// held it privately in <c>DraftValidator</c> and the AdminUI restated it on the equipment form
|
/// held it privately in <c>DraftValidator</c> and the AdminUI restated it on the equipment form
|
||||||
/// alone, so an area or line could be named <c>Packaging</c> or <c>Line 1</c>, saved, and then
|
/// alone, so an area or line could be named <c>Packaging</c> or <c>Line 1</c>, saved, and then
|
||||||
/// fail the deploy with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
|
/// fail the deploy with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
|
/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
|
||||||
/// the S7 driver's bespoke poll fork (05/STAB-8, CONV-1). Provides two surfaces:
|
/// the S7 driver's bespoke poll fork. Provides two surfaces:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><see cref="ComputeDelay"/> — the pure schedule (base, 2×, 4×, … saturating at a
|
/// <item><see cref="ComputeDelay"/> — the pure schedule (base, 2×, 4×, … saturating at a
|
||||||
/// cap) used by <c>PollGroupEngine</c> to stretch its retry cadence under sustained
|
/// cap) used by <c>PollGroupEngine</c> to stretch its retry cadence under sustained
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ using System.Text.Json;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #610 — the single authority on whether a driver can WRITE a 1-D array tag. The address-space
|
/// the single authority on whether a driver can WRITE a 1-D array tag. The address-space
|
||||||
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
|
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
|
||||||
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
|
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
|
||||||
/// materialises as a writable OPC UA node. Before #610 every array node was forced read-only regardless of
|
/// materialises as a writable OPC UA node. Previously every array node was forced read-only regardless of
|
||||||
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
|
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
|
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
|
||||||
@@ -125,16 +125,16 @@ public static class DriverArrayWriteSupport
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||||
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
|
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
|
||||||
/// reverting it when the scalar encoder throws.
|
/// reverting it when the scalar encoder throws.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
|
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
|
||||||
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
|
/// array. Every other driver keeps the previous behaviour — the write is dispatched and the driver
|
||||||
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
|
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
|
||||||
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
|
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
|
||||||
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
|
/// it, and writing that array back worked previously — <c>OpcUaClient</c> forwards the
|
||||||
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
|
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
|
||||||
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
|
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// Sparkplug births, MTConnect probe inference).
|
/// Sparkplug births, MTConnect probe inference).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>It exists because <paramref name="DriverDataType"/> is lossy in exactly the direction a
|
/// <b>It exists because <paramref name="DriverDataType"/> is lossy in exactly the direction a
|
||||||
/// browse-commit needs</b> (Gitea #536): <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
|
/// browse-commit needs</b>: <c>AbCipDataType.SInt</c>, <c>.Int</c> and <c>.DInt</c>
|
||||||
/// all map to <see cref="DriverDataType.Int32"/>, so a commit that reverse-mapped the
|
/// all map to <see cref="DriverDataType.Int32"/>, so a commit that reverse-mapped the
|
||||||
/// driver-agnostic type would bind a 16-bit INT as a 32-bit DINT. Nothing downstream of the
|
/// driver-agnostic type would bind a 16-bit INT as a 32-bit DINT. Nothing downstream of the
|
||||||
/// address-space build reads this — it is authoring metadata carried alongside the type the
|
/// address-space build reads this — it is authoring metadata carried alongside the type the
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public enum DriverCapability
|
|||||||
AlarmAcknowledge,
|
AlarmAcknowledge,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>IDriver.InitializeAsync</c> — connection establishment (R2-10 T10 closure: without this,
|
/// <c>IDriver.InitializeAsync</c> — connection establishment (without this,
|
||||||
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
|
/// a driver that cannot connect contributes zero breaker input and the resilience panel stays
|
||||||
/// quiet for the failure operators most care about). Does NOT retry — the
|
/// quiet for the failure operators most care about). Does NOT retry — the
|
||||||
/// <c>DriverInstanceActor</c> reconnect timer is the retry loop, and pipeline-level retries
|
/// <c>DriverInstanceActor</c> reconnect timer is the retry loop, and pipeline-level retries
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an audit row
|
// OPC UA Part 11 modified-value history: every HistoryUpdate a backend applies leaves an audit row
|
||||||
// carrying the PRIOR value, who made the change and when; HistoryRead with IsReadModified=true serves them.
|
// carrying the PRIOR value, who made the change and when; HistoryRead with IsReadModified=true serves them.
|
||||||
|
|
||||||
/// <summary>Who is performing a HistoryUpdate. Threaded into every <see cref="IHistorianDataUpdater"/> call.</summary>
|
/// <summary>Who is performing a HistoryUpdate. Threaded into every <see cref="IHistorianDataUpdater"/> call.</summary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
|
// Shared history result contracts used by IHistorianDataSource and the historian gateway.
|
||||||
// Drivers carry no history capability (Gitea #655): HistoryRead is served server-side.
|
// Drivers carry no history capability: HistoryRead is served server-side.
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Result of a HistoryRead call.</summary>
|
/// <summary>Result of a HistoryRead call.</summary>
|
||||||
@@ -13,7 +13,7 @@ public sealed record HistoryReadResult(
|
|||||||
|
|
||||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The first five members are the original set every historian backend serves. The remainder (#285) are
|
/// The first five members are the original set every historian backend serves. The remainder are
|
||||||
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
|
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
|
||||||
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
|
/// supports them (a gateway backend maps them to its own aggregate modes). A backend that cannot serve a
|
||||||
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
|
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
|
||||||
@@ -48,14 +48,14 @@ public enum HistoryAggregateType
|
|||||||
StandardDeviationPopulation,
|
StandardDeviationPopulation,
|
||||||
VariancePopulation,
|
VariancePopulation,
|
||||||
|
|
||||||
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only (#671).</summary>
|
/// <summary>Part 13 TimeAverage2: time-weighted average over the interval's non-Bad data only.</summary>
|
||||||
TimeAverage2,
|
TimeAverage2,
|
||||||
|
|
||||||
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only (#671).</summary>
|
/// <summary>Part 13 Total2: time-weighted integral (value·seconds) over the interval's non-Bad data only.</summary>
|
||||||
Total2,
|
Total2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/> (#682).</summary>
|
/// <summary>Shared <see cref="HistoryAggregateType"/> sets for <see cref="IHistorianDataSource.SupportedAggregates"/>.</summary>
|
||||||
public static class HistoryAggregateTypes
|
public static class HistoryAggregateTypes
|
||||||
{
|
{
|
||||||
/// <summary>Every defined <see cref="HistoryAggregateType"/> — the interface default.</summary>
|
/// <summary>Every defined <see cref="HistoryAggregateType"/> — the interface default.</summary>
|
||||||
@@ -85,7 +85,7 @@ public sealed class HistoryAggregateNotSupportedException : NotSupportedExceptio
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thrown by a history backend when a read would exceed a configured size limit (e.g. the raw-row cap of the SQL
|
/// Thrown by a history backend when a read would exceed a configured size limit (e.g. the raw-row cap of the SQL
|
||||||
/// backends' time-weighted aggregates, #671). The Server HistoryRead path translates it to
|
/// backends' time-weighted aggregates). The Server HistoryRead path translates it to
|
||||||
/// <c>BadTooManyOperations</c> for that node only; it is a client-request limit, not a backend fault.
|
/// <c>BadTooManyOperations</c> for that node only; it is a client-request limit, not a backend fault.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HistoryReadLimitExceededException : InvalidOperationException
|
public sealed class HistoryReadLimitExceededException : InvalidOperationException
|
||||||
@@ -94,7 +94,7 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
|
|||||||
public long Limit { get; }
|
public long Limit { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #694: a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
|
/// a raw read refused because rows BEFORE its window (inside the backend's look-back margin — a tie
|
||||||
/// cluster just before a page start, the HistorianGateway's boundary row) crowded a ceiling-bounded row budget: how
|
/// cluster just before a page start, the HistorianGateway's boundary row) crowded a ceiling-bounded row budget: how
|
||||||
/// many such rows the read saw (a lower bound when the read was itself truncated). <c>0</c> = not a crowding refusal.
|
/// many such rows the read saw (a lower bound when the read was itself truncated). <c>0</c> = not a crowding refusal.
|
||||||
/// The server re-reads such a page once, smaller by this many values (<c>RawPageLimit</c>).
|
/// The server re-reads such a page once, smaller by this many values (<c>RawPageLimit</c>).
|
||||||
@@ -127,7 +127,7 @@ public sealed record HistoricalEvent(
|
|||||||
ushort Severity);
|
ushort Severity);
|
||||||
|
|
||||||
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||||
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read (#675).</param>
|
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read.</param>
|
||||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||||
public sealed record HistoricalEventsResult(
|
public sealed record HistoricalEventsResult(
|
||||||
IReadOnlyList<HistoricalEvent> Events,
|
IReadOnlyList<HistoricalEvent> Events,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This is a *server registration*, not a driver capability: drivers carry no history
|
/// This is a *server registration*, not a driver capability: drivers carry no history
|
||||||
/// surface (Gitea #655). The server calls it directly, so a single historian can serve
|
/// surface. The server calls it directly, so a single historian can serve
|
||||||
/// many drivers' nodes, and drivers can restart without dropping history availability.
|
/// many drivers' nodes, and drivers can restart without dropping history availability.
|
||||||
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
|
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
|
||||||
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
|
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
|
||||||
@@ -20,7 +20,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Forward (<c>startUtc < endUtc</c>) is <c>[start, end)</c> ascending; <c>startUtc == endUtc</c> is a point read.
|
/// Forward (<c>startUtc < endUtc</c>) is <c>[start, end)</c> ascending; <c>startUtc == endUtc</c> is a point read.
|
||||||
/// <c>startUtc > endUtc</c> is the Part 11 reverse read <c>(end, start]</c> newest-first — but only a backend that
|
/// <c>startUtc > endUtc</c> is the Part 11 reverse read <c>(end, start]</c> newest-first — but only a backend that
|
||||||
/// declares <see cref="SupportsReverseRaw"/> is ever sent one (Gitea #686); the server serves a reverse read on any
|
/// declares <see cref="SupportsReverseRaw"/> is ever sent one; the server serves a reverse read on any
|
||||||
/// other backend from bounded forward reads.
|
/// other backend from bounded forward reads.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||||
@@ -38,7 +38,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this backend can serve a reverse (newest-first) raw read itself — <see cref="ReadRawAsync"/> with
|
/// Whether this backend can serve a reverse (newest-first) raw read itself — <see cref="ReadRawAsync"/> with
|
||||||
/// <c>startUtc > endUtc</c> (Gitea #686). The server checks it before every raw read. When <c>false</c> it never
|
/// <c>startUtc > endUtc</c>. The server checks it before every raw read. When <c>false</c> it never
|
||||||
/// sends <c>start > end</c>: it serves the reverse read — an explicit one, a resumed reverse page, and the
|
/// sends <c>start > end</c>: it serves the reverse read — an explicit one, a resumed reverse page, and the
|
||||||
/// end-only capped read Part 11 reads backward from EndTime — from a series of bounded FORWARD reads walking back
|
/// end-only capped read Part 11 reads backward from EndTime — from a series of bounded FORWARD reads walking back
|
||||||
/// from the later bound (<c>ReverseRawReader</c> in the OPC UA server). Default <c>false</c>, the safe direction: a
|
/// from the later bound (<c>ReverseRawReader</c> in the OPC UA server). Default <c>false</c>, the safe direction: a
|
||||||
@@ -48,11 +48,11 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The most values one capped <see cref="ReadRawAsync"/> can currently return without failing
|
/// The most values one capped <see cref="ReadRawAsync"/> can currently return without failing
|
||||||
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling (Gitea #694); <c>0</c> = no known limit.
|
/// <see cref="HistoryReadLimitExceededException"/> on a BACKEND row ceiling; <c>0</c> = no known limit.
|
||||||
/// The server sizes its raw pages to it — a page is at most this many values and carries a continuation point when
|
/// The server sizes its raw pages to it — a page is at most this many values and carries a continuation point when
|
||||||
/// full (Part 11 allows fewer than <c>NumValuesPerNode</c> with a continuation point), instead of failing a page size
|
/// full (Part 11 allows fewer than <c>NumValuesPerNode</c> with a continuation point), instead of failing a page size
|
||||||
/// the backend cannot read in one go. It may change at runtime (a strict HistorianGateway's ceiling is learned from
|
/// the backend cannot read in one go. It may change at runtime (a strict HistorianGateway's ceiling is learned from
|
||||||
/// its first refusal, #691.3), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
|
/// its first refusal), so it is read per read. A backend whose reads are never ceiling-bound returns <c>0</c>.
|
||||||
/// The source still never truncates: a read asking for more than this is answered in full or refused. Default
|
/// The source still never truncates: a read asking for more than this is answered in full or refused. Default
|
||||||
/// <c>0</c>; a backend declares it itself (a guard test pins that).
|
/// <c>0</c>; a backend declares it itself (a guard test pins that).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -94,7 +94,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #693: <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
|
/// <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> with the value type of
|
||||||
/// the node being read, for a backend whose stored values do not carry their type (the Gateway: AVEVA stores every
|
/// the node being read, for a backend whose stored values do not carry their type (the Gateway: AVEVA stores every
|
||||||
/// historized type as a numeric analog, so a Boolean comes back as a 0/1 Double). Such a backend uses the hint to apply
|
/// historized type as a numeric analog, so a Boolean comes back as a 0/1 Double). Such a backend uses the hint to apply
|
||||||
/// the AtTime rules exactly as the typed backends do — a Boolean (or String / DateTime) node is stepped, an integer node
|
/// the AtTime rules exactly as the typed backends do — a Boolean (or String / DateTime) node is stepped, an integer node
|
||||||
@@ -156,17 +156,17 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
||||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/>. The server
|
||||||
/// checks it BEFORE calling the source: a reverse read (explicit <c>StartTime > EndTime</c>, or an end-only capped
|
/// checks it BEFORE calling the source: a reverse read (explicit <c>StartTime > EndTime</c>, or an end-only capped
|
||||||
/// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea
|
/// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea
|
||||||
/// #691.4 the server walks it back from bounded FORWARD reads instead (#675 refused it). Default <c>false</c>; an
|
/// the server walks it back from bounded FORWARD reads instead (it was once refused). Default <c>false</c>; an
|
||||||
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool SupportsReverseEvents => false;
|
bool SupportsReverseEvents => false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
|
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
|
||||||
/// paging calls (Gitea #675).
|
/// paging calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -182,7 +182,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// The default forwards a forward read to the five-argument overload and throws
|
/// The default forwards a forward read to the five-argument overload and throws
|
||||||
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (the
|
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (the
|
||||||
/// Gateway). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and walks
|
/// Gateway). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and walks
|
||||||
/// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if
|
/// such a read back from forward reads, never returning the earliest events in ascending order as if
|
||||||
/// they were the answer. A backend that serves reverse natively must override BOTH members.
|
/// they were the answer. A backend that serves reverse natively must override BOTH members.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -228,7 +228,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
long TimestampResolutionTicks => 1;
|
long TimestampResolutionTicks => 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Part 13 aggregates this backend serves for a processed HistoryRead (Gitea #682) — the ONE authority for
|
/// The Part 13 aggregates this backend serves for a processed HistoryRead — the ONE authority for
|
||||||
/// both what the server advertises under <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) and what
|
/// both what the server advertises under <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) and what
|
||||||
/// its HistoryRead-Processed answers <c>BadAggregateNotSupported</c> up front without calling the backend.
|
/// its HistoryRead-Processed answers <c>BadAggregateNotSupported</c> up front without calling the backend.
|
||||||
/// (<c>ServerCapabilities/AggregateFunctions</c>, i=2997, is not affected: it lists every aggregate the server
|
/// (<c>ServerCapabilities/AggregateFunctions</c>, i=2997, is not affected: it lists every aggregate the server
|
||||||
@@ -236,7 +236,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The default is <see cref="HistoryAggregateTypes.All"/> — "every aggregate the server can name; a member I cannot
|
/// The default is <see cref="HistoryAggregateTypes.All"/> — "every aggregate the server can name; a member I cannot
|
||||||
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the pre-#682
|
/// serve throws <see cref="HistoryAggregateNotSupportedException"/> at read time", which is exactly the earlier
|
||||||
/// behaviour, so a test fake or an out-of-tree source keeps working unchanged. It is a concrete set rather than a
|
/// behaviour, so a test fake or an out-of-tree source keeps working unchanged. It is a concrete set rather than a
|
||||||
/// <c>null</c> "all" sentinel so every consumer handles one shape. Every PRODUCTION backend declares its set itself
|
/// <c>null</c> "all" sentinel so every consumer handles one shape. Every PRODUCTION backend declares its set itself
|
||||||
/// (a reflection guard pins that): a wrapper that forgot to forward this would otherwise advertise every aggregate.
|
/// (a reflection guard pins that): a wrapper that forgot to forward this would otherwise advertise every aggregate.
|
||||||
@@ -247,7 +247,7 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
|
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11, Gitea #668):
|
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11):
|
||||||
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
|
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ public enum HistoryUpdateMode
|
|||||||
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
|
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Modified-value history (Part 11, Gitea #668).</b> Every sample an implementation inserts, replaces or deletes
|
/// <b>Modified-value history (Part 11).</b> Every sample an implementation inserts, replaces or deletes
|
||||||
/// MUST leave one modification record carrying the prior value (none for an insert), the
|
/// MUST leave one modification record carrying the prior value (none for an insert), the
|
||||||
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
|
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
|
||||||
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
|
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
|
||||||
/// no record. Live recorder writes and retention purges never leave one.
|
/// no record. Live recorder writes and retention purges never leave one.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Cannot update at all</b> (Gitea #674): an implementation that is wired but cannot perform any update right
|
/// <b>Cannot update at all</b>: an implementation that is wired but cannot perform any update right
|
||||||
/// now — the SqlServer backend on a schema without the modification trail — throws
|
/// now — the SqlServer backend on a schema without the modification trail — throws
|
||||||
/// <see cref="NotSupportedException"/> from every method without touching storage. The server reports it as a
|
/// <see cref="NotSupportedException"/> from every method without touching storage. The server reports it as a
|
||||||
/// node-level <c>BadHistoryOperationUnsupported</c>, the same as <see cref="IHistorianDataSource.ReadModifiedAsync"/>.
|
/// node-level <c>BadHistoryOperationUnsupported</c>, the same as <see cref="IHistorianDataSource.ReadModifiedAsync"/>.
|
||||||
|
|||||||
@@ -56,21 +56,21 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
|||||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks><c>true</c> (Gitea #686): there is no history, so the empty result is the correct answer in either
|
// true: there is no history, so the empty result is the correct answer in either
|
||||||
/// direction — no need for the server to walk back through forward reads to find nothing.</remarks>
|
// direction — no need for the server to walk back through forward reads to find nothing.
|
||||||
public bool SupportsReverseRaw => true;
|
public bool SupportsReverseRaw => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks><c>0</c>: no history, no ceiling (Gitea #694).</remarks>
|
// 0: no history, no ceiling.
|
||||||
public int MaxRawValuesPerRead => 0;
|
public int MaxRawValuesPerRead => 0;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks><c>true</c>, like <see cref="SupportsReverseRaw"/> (Gitea #691.4 review; <c>false</c> under #675): there is no
|
// true, like SupportsReverseRaw: there is no history to read in either direction —
|
||||||
/// history to read in either direction — no need for the server to walk back through empty forward reads.</remarks>
|
// no need for the server to walk back through empty forward reads.
|
||||||
public bool SupportsReverseEvents => true;
|
public bool SupportsReverseEvents => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>The empty result in either direction.</remarks>
|
// The empty result in either direction.
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
string? sourceName,
|
string? sourceName,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -81,12 +81,10 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
|||||||
Task.FromResult(EmptyEvents);
|
Task.FromResult(EmptyEvents);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
// EMPTY: with no historian configured the server advertises no aggregate functions, and a processed
|
||||||
/// EMPTY (Gitea #682): with no historian configured the server advertises no aggregate functions, and a processed
|
// HistoryRead answers BadAggregateNotSupported per node rather than GoodNoData — an aggregate is a
|
||||||
/// HistoryRead answers <c>BadAggregateNotSupported</c> per node rather than <c>GoodNoData</c> — an aggregate is a
|
// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
|
||||||
/// statistic the server cannot compute without a history, and advertising the SDK's full list on a server with no
|
// historian misled capability discovery. Raw / AtTime / Events reads still return the empty GoodNoData result.
|
||||||
/// historian misled capability discovery. Raw / AtTime / Events reads still return the empty <c>GoodNoData</c> result.
|
|
||||||
/// </remarks>
|
|
||||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
|
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
||||||
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
||||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
|
/// (CNC alarms), OPC UA Client (A&C events from upstream server), MTConnect
|
||||||
/// (CONDITION data items, Gitea #626).
|
/// (CONDITION data items).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAlarmSource
|
public interface IAlarmSource
|
||||||
{
|
{
|
||||||
@@ -88,22 +88,22 @@ public sealed record AlarmAcknowledgeRequest(
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="OpcUaSeverity">
|
/// <param name="OpcUaSeverity">
|
||||||
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
|
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
|
||||||
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
|
/// <see cref="AlarmSeverity"/> buckets (MTConnect Warning = 500, Fault = 800). When set it
|
||||||
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
|
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
|
||||||
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
/// earlier source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
|
||||||
/// bucket for consumers that only read the bucket.
|
/// bucket for consumers that only read the bucket.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="QualityStatusCode">
|
/// <param name="QualityStatusCode">
|
||||||
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
|
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
|
||||||
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
|
/// source that can report "no value for this condition" per alarm rather than per connection (for example
|
||||||
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
|
/// MTConnect <c>Unavailable</c>). <c>null</c> keeps the earlier behaviour (projected Good;
|
||||||
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
|
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
|
||||||
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
|
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
|
||||||
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
|
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
|
||||||
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="IsRestatement">
|
/// <param name="IsRestatement">
|
||||||
/// Gitea #656 — true when the source is RE-STATING upstream's current state rather than reporting a transition it
|
/// True when the source is RE-STATING upstream's current state rather than reporting a transition it
|
||||||
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
|
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
|
||||||
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
|
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
|
||||||
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
|
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
|
||||||
@@ -111,7 +111,7 @@ public sealed record AlarmAcknowledgeRequest(
|
|||||||
/// Acknowledge on an already-acknowledged condition) — or
|
/// Acknowledge on an already-acknowledged condition) — or
|
||||||
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
|
/// when a flagged <see cref="AlarmTransitionKind.Unspecified"/> update moves the message/severity of an
|
||||||
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
|
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
|
||||||
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
|
/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient re-states a
|
||||||
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
|
/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
|
||||||
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
|
/// that writes nothing and a host retaining it Active gets a real "Cleared". <c>false</c> (the default, every source that does not
|
||||||
/// opt in) keeps every transition's row.
|
/// opt in) keeps every transition's row.
|
||||||
@@ -147,9 +147,9 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
|
|||||||
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
|
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
|
||||||
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
|
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
|
||||||
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
|
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
|
||||||
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
|
/// <see cref="ActiveAcknowledged"/> means "the condition is active AND acknowledged", applied by the
|
||||||
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
|
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
|
||||||
/// <see cref="InactiveUnacknowledged"/> (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
|
/// <see cref="InactiveUnacknowledged"/> is its mirror: "the condition is inactive AND unacknowledged"
|
||||||
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
|
/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
|
||||||
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
|
/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
|
||||||
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
|
/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public interface IDriver
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply a config change in place without tearing down the driver process.
|
/// Apply a config change in place without tearing down the driver process.
|
||||||
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
|
/// <para>The runtime does not call this today: a DriverConfig change is a stop + respawn,
|
||||||
/// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
|
/// and <c>DriverInstanceActor</c> no longer has an in-place <c>ApplyDelta</c> path. Kept as the in-place
|
||||||
/// contract each driver implements and tests.</para>
|
/// contract each driver implements and tests.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -40,12 +40,12 @@ public interface IDriver
|
|||||||
|
|
||||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b> (Gitea #517).
|
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b>.
|
||||||
/// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a
|
/// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a
|
||||||
/// driver that wrote <see cref="DriverState.Unknown"/> first and then tore down could have a late
|
/// driver that wrote <see cref="DriverState.Unknown"/> first and then tore down could have a late
|
||||||
/// loop overwrite it with <see cref="DriverState.Healthy"/> and leave THAT as the resting state,
|
/// loop overwrite it with <see cref="DriverState.Healthy"/> and leave THAT as the resting state,
|
||||||
/// with every connection already closed. This is why the fleet writes last, and it is the reason
|
/// with every connection already closed. This is why the fleet writes last, and it is the reason
|
||||||
/// #517's original suggestion — "write the transitional value before disposing" — is only safe as an
|
/// The original suggestion — "write the transitional value before disposing" — is only safe as an
|
||||||
/// ADDITIONAL write, bracketing the teardown, rather than as a move.</para>
|
/// ADDITIONAL write, bracketing the teardown, rather than as a move.</para>
|
||||||
/// <para>Bracketing matters wherever a <see cref="GetHealth"/> can land mid-teardown. It cannot on
|
/// <para>Bracketing matters wherever a <see cref="GetHealth"/> can land mid-teardown. It cannot on
|
||||||
/// this path today: <c>DriverInstanceActor</c> calls this from <c>PostStop</c>, on the actor thread,
|
/// this path today: <c>DriverInstanceActor</c> calls this from <c>PostStop</c>, on the actor thread,
|
||||||
@@ -61,7 +61,7 @@ public interface IDriver
|
|||||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.
|
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.
|
||||||
/// <para>Must reflect the driver's ACTUAL state rather than a constant — a hard-coded
|
/// <para>Must reflect the driver's ACTUAL state rather than a constant — a hard-coded
|
||||||
/// <see cref="DriverState.Healthy"/> reports a healthy driver after shutdown and before initialize
|
/// <see cref="DriverState.Healthy"/> reports a healthy driver after shutdown and before initialize
|
||||||
/// alike, which is what Gitea #517 found in <c>CalculationDriver</c>. A driver with no transport still
|
/// alike, which was found in <c>CalculationDriver</c>. A driver with no transport still
|
||||||
/// has a running / not-running distinction worth reporting.</para></summary>
|
/// has a running / not-running distinction worth reporting.</para></summary>
|
||||||
/// <returns>The driver's current health snapshot.</returns>
|
/// <returns>The driver's current health snapshot.</returns>
|
||||||
DriverHealth GetHealth();
|
DriverHealth GetHealth();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public interface IDriverCapabilityInvoker
|
|||||||
/// <paramref name="isIdempotent"/> is <c>false</c>, retries are disabled regardless of the
|
/// <paramref name="isIdempotent"/> is <c>false</c>, retries are disabled regardless of the
|
||||||
/// configured policy; when <c>true</c>, the call runs through the Write pipeline which may
|
/// configured policy; when <c>true</c>, the call runs through the Write pipeline which may
|
||||||
/// retry if the tier configuration permits. <b>The current production default is <c>false</c></b>
|
/// retry if the tier configuration permits. <b>The current production default is <c>false</c></b>
|
||||||
/// (R2-02/S-8): <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
|
/// <c>DriverInstanceActor.HandleWriteAsync</c> passes <c>isIdempotent: false</c> so a
|
||||||
/// command-shaped write is never replayed; per-tag opt-in via <see cref="WriteIdempotentAttribute"/>
|
/// command-shaped write is never replayed; per-tag opt-in via <see cref="WriteIdempotentAttribute"/>
|
||||||
/// is the unshipped forward path.
|
/// is the unshipped forward path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public interface IDriverCapabilityInvokerFactory
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Release every piece of shared resilience state held for <paramref name="driverInstanceId"/> — called
|
/// Release every piece of shared resilience state held for <paramref name="driverInstanceId"/> — called
|
||||||
/// by the host when the instance leaves the deployed plan (#609). A concrete factory drops the
|
/// by the host when the instance leaves the deployed plan. A concrete factory drops the
|
||||||
/// instance's cached pipelines and its status-tracker snapshots, so the periodic resilience publisher
|
/// instance's cached pipelines and its status-tracker snapshots, so the periodic resilience publisher
|
||||||
/// stops re-emitting (and re-priming new telemetry subscribers with) a decommissioned driver's last
|
/// stops re-emitting (and re-priming new telemetry subscribers with) a decommissioned driver's last
|
||||||
/// breaker/failure counters. Default: no-op (a factory that holds no state).
|
/// breaker/failure counters. Default: no-op (a factory that holds no state).
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional driver capability (Gitea #676) — a driver that can tell, from its protocol, that its connection is
|
/// Optional driver capability — a driver that can tell, from its protocol, that its connection is
|
||||||
/// DEFINITIVELY gone (it can never recover in place, only a re-initialise brings it back) raises
|
/// DEFINITIVELY gone (it can never recover in place, only a re-initialise brings it back) raises
|
||||||
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the #527 comms-loss
|
/// <see cref="ConnectionLost"/> so the host re-initialises it at once instead of waiting out the comms-loss
|
||||||
/// watchdog (~90 s of frozen <see cref="DriverHealth.LastSuccessfulRead"/>).
|
/// watchdog (~90 s of frozen <see cref="DriverHealth.LastSuccessfulRead"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
|
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
|
||||||
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
|
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
|
||||||
/// page (Gitea #640).
|
/// page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ public interface IDriverHealthPublisher
|
|||||||
/// Authored tags the driver could not build into its poll table, from
|
/// Authored tags the driver could not build into its poll table, from
|
||||||
/// <see cref="ISkippedTagReporter.GetSkippedTags"/>; null when the driver is not an
|
/// <see cref="ISkippedTagReporter.GetSkippedTags"/>; null when the driver is not an
|
||||||
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
|
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
|
||||||
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
|
/// of a driver that is in fact ignoring part or all of its configuration.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="diagnostics">
|
/// <param name="diagnostics">
|
||||||
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
|
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
|
||||||
/// driver is not an <see cref="IDriverDiagnostics"/> (Gitea #640).
|
/// driver is not an <see cref="IDriverDiagnostics"/>.
|
||||||
/// </param>
|
/// </param>
|
||||||
void Publish(
|
void Publish(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <i>inbound</i>: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
|
/// <i>inbound</i>: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
|
||||||
/// not inbound. It is a standing assertion the driver makes to the broker, on its own
|
/// not inbound. It is a standing assertion the driver makes to the broker, on its own
|
||||||
/// connection, for as long as it holds one — there is no message for the host actor to
|
/// connection, for as long as it holds one — there is no message for the host actor to
|
||||||
/// intercept. So the decision has to reach the driver instead (Gitea #511).
|
/// intercept. So the decision has to reach the driver instead.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The decision, not the role.</b> Same discipline as the alarm-drain view in
|
/// <b>The decision, not the role.</b> Same discipline as the alarm-drain view in
|
||||||
@@ -61,7 +61,7 @@ public interface IDriverPrimaryRoleSource
|
|||||||
/// publishing a role there is by definition no peer to collide with, and a closed default
|
/// publishing a role there is by definition no peer to collide with, and a closed default
|
||||||
/// would make an exclusive-identity claim silently never happen on the single-node deployment
|
/// would make an exclusive-identity claim silently never happen on the single-node deployment
|
||||||
/// that is the common case — an inert feature with no error, which is precisely the failure
|
/// that is the common case — an inert feature with no error, which is precisely the failure
|
||||||
/// mode Gitea #511 was filed about.
|
/// mode this contract exists to prevent.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
|
/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
|
||||||
@@ -83,7 +83,6 @@ public sealed class UnmanagedDriverPrimaryRoleSource : IDriverPrimaryRoleSource
|
|||||||
public bool ShouldActAsPrimary => true;
|
public bool ShouldActAsPrimary => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>No-op: there is no redundancy manager to publish anything here.</remarks>
|
|
||||||
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Why this exists (Gitea #541).</b> Every driver builds its <c>RawPath → definition</c>
|
/// <b>Why this exists.</b> Every driver builds its <c>RawPath → definition</c>
|
||||||
/// table at initialise and <i>tolerates</i> a per-tag miss — an unmappable <c>TagConfig</c>, an
|
/// table at initialise and <i>tolerates</i> a per-tag miss — an unmappable <c>TagConfig</c>, an
|
||||||
/// unparseable address, a capability-matrix rejection. Tolerance is the right call: one bad tag
|
/// unparseable address, a capability-matrix rejection. Tolerance is the right call: one bad tag
|
||||||
/// must not take down the other few thousand. But a skip is not an error and happens before any
|
/// must not take down the other few thousand. But a skip is not an error and happens before any
|
||||||
/// read, so the driver stays <c>Healthy</c>, <c>errors/5 min</c> stays 0, <c>Last error</c> stays
|
/// read, so the driver stays <c>Healthy</c>, <c>errors/5 min</c> stays 0, <c>Last error</c> stays
|
||||||
/// empty, and <c>Last read</c> advances live off the tags that <i>did</i> map. On the Makino in
|
/// empty, and <c>Last read</c> advances live off the tags that <i>did</i> map. On the Makino in
|
||||||
/// Gitea #540 that combination held while the driver ignored 20 of its 22 tags, and no surface
|
/// a past incident that combination held while the driver ignored 20 of its 22 tags, and no surface
|
||||||
/// anywhere said otherwise.
|
/// anywhere said otherwise.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Deliberately additive to driver state, never a change of it.</b> Latching a driver
|
/// <b>Deliberately additive to driver state, never a change of it.</b> Latching a driver
|
||||||
/// non-Healthy over a config problem would collide with the Gitea #527 comms-loss detector,
|
/// non-Healthy over a config problem would collide with the comms-loss detector,
|
||||||
/// which trips on <i>90 s without a successful read AND non-Healthy</i>. A driver whose tags
|
/// which trips on <i>90 s without a successful read AND non-Healthy</i>. A driver whose tags
|
||||||
/// were <i>all</i> skipped has no successful reads by construction, so it would satisfy both
|
/// were <i>all</i> skipped has no successful reads by construction, so it would satisfy both
|
||||||
/// halves and reconnect-loop forever against a device that is perfectly fine. The signal
|
/// halves and reconnect-loop forever against a device that is perfectly fine. The signal
|
||||||
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Implementations should accumulate into a <see cref="SkippedTagLedger"/> rather than
|
/// Implementations should accumulate into a <see cref="SkippedTagLedger"/> rather than
|
||||||
/// hand-rolling a counter: the ledger is one implementation of the reset/commit/cap rules
|
/// hand-rolling a counter: the ledger is one implementation of the reset/commit/cap rules
|
||||||
/// instead of one per driver, which is precisely the duplication #541 was filed about.
|
/// instead of one per driver, which is precisely the duplication this type removes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public interface ISkippedTagReporter
|
public interface ISkippedTagReporter
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
|
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
|
||||||
/// the Calculation driver.
|
/// the Calculation driver.
|
||||||
///
|
///
|
||||||
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
|
/// <para><b>The contract it implements</b> ("the stale-Good family"): a value
|
||||||
/// consumer never reports a quality better than its source's. A node derived from driver values
|
/// consumer never reports a quality better than its source's. A node derived from driver values
|
||||||
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
|
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
|
||||||
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
|
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
|||||||
/// internal contract-violation throw) so the owning driver can route the failure to its
|
/// internal contract-violation throw) so the owning driver can route the failure to its
|
||||||
/// health surface. Defensive: an <c>onError</c> handler that
|
/// health surface. Defensive: an <c>onError</c> handler that
|
||||||
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
||||||
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff
|
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff.
|
||||||
/// (05/STAB-8). When supplied, sustained reader failures stretch the retry cadence
|
/// When supplied, sustained reader failures stretch the retry cadence
|
||||||
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
|
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
|
||||||
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
|
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
|
||||||
/// byte-identical fixed-interval cadence for consumers that have not opted in.
|
/// byte-identical fixed-interval cadence for consumers that have not opted in.
|
||||||
@@ -260,7 +260,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
|||||||
consecutiveFailures = 0;
|
consecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
// Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
|
// Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
|
||||||
// the loop token un-cancelled — a driver-internal timeout CTS (the STAB-14/STAB-15 class) —
|
// the loop token un-cancelled — a driver-internal timeout CTS (the class of failure) —
|
||||||
// must NOT tear the loop down: it falls through to the generic handler, is reported, and the
|
// must NOT tear the loop down: it falls through to the generic handler, is reported, and the
|
||||||
// loop retries with backoff. This is the guard that makes the S7 fork migration safe.
|
// loop retries with backoff. This is the guard that makes the S7 fork migration safe.
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||||
@@ -319,7 +319,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
|||||||
consecutiveFailures = 0;
|
consecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
// Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
|
// Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
|
||||||
// like any other failure instead of killing the loop (STAB-14 engine guard).
|
// like any other failure instead of killing the loop.
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||||
catch (Exception ex) when (!IsFatal(ex))
|
catch (Exception ex) when (!IsFatal(ex))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public enum JsonEnumRead
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the
|
/// Shared TagConfig JSON field readers for the six equipment-tag parsers. Replaces the
|
||||||
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
|
/// six byte-identical private <c>ReadEnum</c> copies with one strict-capable home beside
|
||||||
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
|
/// <see cref="EquipmentTagRefResolver{TDef}"/>.
|
||||||
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
|
/// <para><b>Unknown-key handling is a deliberate non-goal.</b> The TagConfig blob is a SHARED
|
||||||
@@ -62,7 +62,7 @@ public static class TagConfigJson
|
|||||||
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
|
/// AdminUI's typed tag-config editors hold a mutable <c>JsonObject</c> key bag, not a
|
||||||
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
|
/// <see cref="JsonElement"/> — and an authoring gate that RESTATED the accept/reject boundary
|
||||||
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
|
/// would drift from the runtime the moment either side moved, in the one direction that hurts:
|
||||||
/// a value the modal accepts and the driver then rejects (Gitea #549). Both doors reach this
|
/// a value the modal accepts and the driver then rejects. Both doors reach this
|
||||||
/// method.
|
/// method.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -131,7 +131,7 @@ public static class TagConfigJson
|
|||||||
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
|
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strict enum read (R2-11 Phase C — the runtime flip). Absent ⇒ <paramref name="fallback"/>;
|
/// Strict enum read. Absent ⇒ <paramref name="fallback"/>;
|
||||||
/// a present, case-insensitively-parseable string ⇒ the parsed value; a present-but-invalid
|
/// a present, case-insensitively-parseable string ⇒ the parsed value; a present-but-invalid
|
||||||
/// string (a typo) ⇒ the read FAILS (returns <see langword="false"/>) so the runtime parser can
|
/// string (a typo) ⇒ the read FAILS (returns <see langword="false"/>) so the runtime parser can
|
||||||
/// reject the whole tag and the driver surfaces <c>BadNodeIdUnknown</c> — instead of silently
|
/// reject the whole tag and the driver surfaces <c>BadNodeIdUnknown</c> — instead of silently
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Applied to tag-definition POCOs
|
/// Applied to tag-definition POCOs
|
||||||
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
|
/// (e.g. <c>ModbusTagDefinition</c>, <c>S7TagDefinition</c>, OPC UA client tag rows) at the
|
||||||
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> (R2-02/S-8) —
|
/// property or record level. <b>Reserved for the per-tag opt-in; NOT yet consulted</b> —
|
||||||
/// the dispatch site (<c>DriverInstanceActor.HandleWriteAsync</c>) currently treats every write as
|
/// the dispatch site (<c>DriverInstanceActor.HandleWriteAsync</c>) currently treats every write as
|
||||||
/// non-idempotent, so no tag retries a write today. Threading tag-definition metadata to the dispatch
|
/// non-idempotent, so no tag retries a write today. Threading tag-definition metadata to the dispatch
|
||||||
/// seam is the documented forward path; when it ships, the <c>CapabilityInvoker</c> non-idempotent arm
|
/// seam is the documented forward path; when it ships, the <c>CapabilityInvoker</c> non-idempotent arm
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
|
||||||
|
/// <summary>Stable delivery GUIDs for persisted alarm queue rows.</summary>
|
||||||
|
public static class AlarmDeliveryIds
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an existing durable row key into a nonzero version-8 GUID. Version 1 of this encoding hashes
|
||||||
|
/// UTF-8 "OtOpcUa.AlarmDelivery/v1\0" followed by the exact persisted key with SHA-256, takes the first
|
||||||
|
/// 16 bytes, sets the UUID version/variant bits, and interprets the bytes in network order.
|
||||||
|
/// This encoding must remain stable across retries, upgrades and replicated failover.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="queueId">The persisted row key, never a hash recomputed from the current payload.</param>
|
||||||
|
/// <returns>The row's stable delivery identity.</returns>
|
||||||
|
public static Guid FromQueueId(string queueId)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(queueId);
|
||||||
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes("OtOpcUa.AlarmDelivery/v1\0" + queueId));
|
||||||
|
digest[6] = (byte)((digest[6] & 0x0F) | 0x80);
|
||||||
|
digest[8] = (byte)((digest[8] & 0x3F) | 0x80);
|
||||||
|
return new Guid(digest.AsSpan(0, 16), bigEndian: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
@@ -34,4 +35,28 @@ public sealed record AlarmHistorianEvent(
|
|||||||
string Message,
|
string Message,
|
||||||
string User,
|
string User,
|
||||||
string? Comment,
|
string? Comment,
|
||||||
DateTime TimestampUtc);
|
DateTime TimestampUtc)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Delivery identity, minted once for direct sends and replaced by the durable queue's identity when drained.
|
||||||
|
/// Excluded from payload serialization and business equality so redundant enqueues still converge.
|
||||||
|
/// A record copy preserves this identity; an explicitly empty identity is invalid for delivery.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public Guid DeliveryId { get; init; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
|
||||||
|
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
|
||||||
|
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
|
||||||
|
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(AlarmId); hash.Add(EquipmentPath); hash.Add(AlarmName); hash.Add(AlarmTypeName); hash.Add(Severity);
|
||||||
|
hash.Add(EventKind); hash.Add(Message); hash.Add(User); hash.Add(Comment); hash.Add(TimestampUtc);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -603,6 +603,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
|
evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
|
||||||
|
// The durable key survives retry, payload edits, legacy migration and replicated failover.
|
||||||
|
// Never derive delivery identity from a reserialized payload or an individual RPC attempt.
|
||||||
|
if (evt is not null) evt = evt with { DeliveryId = AlarmDeliveryIds.FromQueueId(id) };
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ public static class Part9StateMachine
|
|||||||
|
|
||||||
if (current.Acked == AlarmAckedState.Acknowledged)
|
if (current.Acked == AlarmAckedState.Acknowledged)
|
||||||
{
|
{
|
||||||
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (Gitea #597 review
|
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (review
|
||||||
// m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
|
// finding m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
|
||||||
// record must hold it too, or the event advertises a comment that exists only on the node that
|
// record must hold it too, or the event advertises a comment that exists only on the node that
|
||||||
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
|
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
|
||||||
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
|
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
|
||||||
@@ -387,7 +387,7 @@ public enum EmissionKind
|
|||||||
Enabled,
|
Enabled,
|
||||||
Disabled,
|
Disabled,
|
||||||
CommentAdded,
|
CommentAdded,
|
||||||
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
/// <summary>The worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||||
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
||||||
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||||
/// state change and must not materialize or historize a condition).</summary>
|
/// state change and must not materialize or historize a condition).</summary>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
/// Last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||||
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
||||||
/// with no accompanying Part 9 state transition drives a standalone
|
/// with no accompanying Part 9 state transition drives a standalone
|
||||||
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
||||||
@@ -384,7 +384,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
/// 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
|
/// 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
|
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||||
/// the next transition.
|
/// the next transition.
|
||||||
@@ -740,7 +740,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
RefillReadCache(scratch.ReadCache, state.Inputs);
|
||||||
|
|
||||||
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
// Worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||||
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
||||||
// transition is delivered out of band as a QualityChanged emission (see below).
|
// transition is delivered out of band as a QualityChanged emission (see below).
|
||||||
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
||||||
@@ -881,13 +881,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||||
// unaffected (it is not gated on this flag).
|
// unaffected (it is not gated on this flag).
|
||||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||||
// #478 — the worst input quality at evaluation time rides the transition so the projected
|
// The worst input quality at evaluation time rides the transition so the projected
|
||||||
// full snapshot keeps quality consistent (no clobber-to-Good).
|
// full snapshot keeps quality consistent (no clobber-to-Good).
|
||||||
WorstInputStatusCode: worstInputStatus);
|
WorstInputStatusCode: worstInputStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
/// Build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||||
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
||||||
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
||||||
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||||
@@ -913,14 +913,14 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
|
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
|
||||||
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
|
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
|
||||||
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
|
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
|
||||||
/// <para><b>"Never pushed" is a specific status code, not "the value is null" (#481, closing #478
|
/// <para><b>"Never pushed" is a specific status code, not "the value is null"
|
||||||
/// Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
|
/// (Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
|
||||||
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
|
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
|
||||||
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
|
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
|
||||||
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
|
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
|
||||||
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
|
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
|
||||||
/// observation.</para>
|
/// observation.</para>
|
||||||
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
|
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b>
|
||||||
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
|
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
|
||||||
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
|
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
|
||||||
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
|
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
|
||||||
@@ -952,7 +952,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
return severity >= 2 ? 2 : (int)severity;
|
return severity >= 2 ? 2 : (int)severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
/// <summary>A canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||||
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
||||||
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
||||||
private uint LastWorstStatus(string alarmId)
|
private uint LastWorstStatus(string alarmId)
|
||||||
@@ -1264,14 +1264,14 @@ public sealed record ScriptedAlarmEvent(
|
|||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
string? Comment = null,
|
string? Comment = null,
|
||||||
bool HistorizeToAveva = true,
|
bool HistorizeToAveva = true,
|
||||||
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
|
// The worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
|
||||||
// since Core.ScriptedAlarms does not reference Commons. Since #519 the host carries it to the condition
|
// since Core.ScriptedAlarms does not reference Commons. The host carries it to the condition
|
||||||
// node UNCHANGED — it used to be collapsed to a 3-state bucket and re-expanded, losing the sub-code.
|
// node UNCHANGED — it used to be collapsed to a 3-state bucket and re-expanded, losing the sub-code.
|
||||||
// Default 0u == Good keeps every existing constructor call unchanged.
|
// Default 0u == Good keeps every existing constructor call unchanged.
|
||||||
uint WorstInputStatusCode = 0u);
|
uint WorstInputStatusCode = 0u);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
/// 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
|
/// 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
|
/// 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
|
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|
||||||
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
// QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||||
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
||||||
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
||||||
if (evt.Emission == EmissionKind.QualityChanged) return;
|
if (evt.Emission == EmissionKind.QualityChanged) return;
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public static class ScriptSandbox
|
|||||||
// surface (operator-authored hosting helpers, third-party packages, app code)
|
// surface (operator-authored hosting helpers, third-party packages, app code)
|
||||||
// is not on the list and stays unreachable.
|
// is not on the list and stays unreachable.
|
||||||
var references = new List<MetadataReference>();
|
var references = new List<MetadataReference>();
|
||||||
// #603: both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
|
// both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
|
||||||
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
|
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
|
||||||
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
|
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
|
||||||
foreach (var asm in pinnedAssemblies)
|
foreach (var asm in pinnedAssemblies)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>Security:Acl</c> — the switch that turns per-node ACL evaluation into the authorization
|
/// <c>Security:Acl</c> — the switch that turns per-node ACL evaluation into the authorization
|
||||||
/// decision on the OPC UA data plane (#520, stage 5).
|
/// decision on the OPC UA data plane.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -39,7 +39,7 @@ public sealed class AclOptions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default
|
/// When true, node ACL grants decide write, alarm-command and HistoryRead authorization. Default
|
||||||
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
/// false, which preserves the previous role gates byte-for-byte.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ public sealed class AclOptions
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
|
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
|
||||||
/// pre-R3-05 behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
/// previous behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
||||||
/// pass early-returns when ACL enforcement is off.
|
/// pass early-returns when ACL enforcement is off.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// wrong-node authorization rather than a visible error.
|
/// wrong-node authorization rather than a visible error.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The string-identity rule for the whole authorization path (Gitea #533), stated here because
|
/// <b>The string-identity rule for the whole authorization path, stated here because
|
||||||
/// this type is where it was first violated:</b>
|
/// this type is where it was first violated:</b>
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// mean the same entity, and the sources genuinely differ in case (a <c>ClusterId</c> arrives
|
/// mean the same entity, and the sources genuinely differ in case (a <c>ClusterId</c> arrives
|
||||||
/// lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through <c>Cluster:Roles</c> — see
|
/// lower-cased through <c>OTOPCUA_ROLES</c> and verbatim through <c>Cluster:Roles</c> — see
|
||||||
/// <c>RoleParser.ClusterIdFromRole</c> — and a <c>ScopeId</c> is operator free-text in the ACL
|
/// <c>RoleParser.ClusterIdFromRole</c> — and a <c>ScopeId</c> is operator free-text in the ACL
|
||||||
/// editor until the stage-7 picker lands).
|
/// editor until the scope picker lands).
|
||||||
/// </item>
|
/// </item>
|
||||||
/// <item>
|
/// <item>
|
||||||
/// <b>Path segments compare <c>Ordinal</c></b> — every level of <see cref="NodeAclPath"/>,
|
/// <b>Path segments compare <c>Ordinal</c></b> — every level of <see cref="NodeAclPath"/>,
|
||||||
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// </item>
|
/// </item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Before #533 this record struct used the compiler's default equality, i.e. ordinal, while the
|
/// Previously this record struct used the compiler's default equality, i.e. ordinal, while the
|
||||||
/// trie builder filtered <c>ClusterId</c> and hashed principals <c>OrdinalIgnoreCase</c> — three
|
/// trie builder filtered <c>ClusterId</c> and hashed principals <c>OrdinalIgnoreCase</c> — three
|
||||||
/// rules in one path. It worked only because the resolver and the builder were handed the same
|
/// rules in one path. It worked only because the resolver and the builder were handed the same
|
||||||
/// <c>NodeAcl</c> instances, so the <c>ScopeId</c> strings were literally the same object. Feed
|
/// <c>NodeAcl</c> instances, so the <c>ScopeId</c> strings were literally the same object. Feed
|
||||||
@@ -49,9 +49,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// <param name="ScopeId">The authored scope id.</param>
|
/// <param name="ScopeId">The authored scope id.</param>
|
||||||
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
|
public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
|
||||||
{
|
{
|
||||||
/// <summary>Equality over the kind plus a case-insensitive id — see the rule on the type.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="other">The other reference.</param>
|
|
||||||
/// <returns><see langword="true"/> when both name the same scope.</returns>
|
|
||||||
public bool Equals(AclScopeRef other) =>
|
public bool Equals(AclScopeRef other) =>
|
||||||
Kind == other.Kind && string.Equals(ScopeId, other.ScopeId, StringComparison.OrdinalIgnoreCase);
|
Kind == other.Kind && string.Equals(ScopeId, other.ScopeId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -62,7 +60,7 @@ public readonly record struct AclScopeRef(NodeAclScopeKind Kind, string ScopeId)
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
|
/// Produces the <c>scopePaths</c> lookup <see cref="PermissionTrieBuilder.Build"/> consumes: for each
|
||||||
/// ACL scope, the trie path(s) at which a grant on it must be planted (#520 stage 3).
|
/// ACL scope, the trie path(s) at which a grant on it must be planted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -131,7 +129,7 @@ public sealed class AclScopePathResolver
|
|||||||
ArgumentNullException.ThrowIfNull(references);
|
ArgumentNullException.ThrowIfNull(references);
|
||||||
|
|
||||||
// Every map is re-keyed OrdinalIgnoreCase rather than used as handed in. Ids are identifiers, so
|
// Every map is re-keyed OrdinalIgnoreCase rather than used as handed in. Ids are identifiers, so
|
||||||
// they follow the identifier half of the rule on AclScopeRef (Gitea #533) — and doing it HERE,
|
// they follow the identifier half of the rule on AclScopeRef — and doing it HERE,
|
||||||
// rather than documenting a comparer contract for callers, is what makes the rule impossible to
|
// rather than documenting a comparer contract for callers, is what makes the rule impossible to
|
||||||
// get wrong from the outside: the two producers (EF entities at the deploy gate, artifact JSON on
|
// get wrong from the outside: the two producers (EF entities at the deploy gate, artifact JSON on
|
||||||
// the node) build these dictionaries independently and neither can now diverge from the other.
|
// the node) build these dictionaries independently and neither can now diverge from the other.
|
||||||
@@ -224,7 +222,7 @@ public sealed class AclScopePathResolver
|
|||||||
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
|
// Cluster never reaches here (the caller short-circuits it), and FolderSegment is the retired
|
||||||
// v2 kind with no v3 entity behind it — deliberately unresolvable. The trie builder now
|
// v2 kind with no v3 entity behind it — deliberately unresolvable. The trie builder now
|
||||||
// short-circuits FolderSegment too, so it reports RetiredScopeKind rather than the
|
// short-circuits FolderSegment too, so it reports RetiredScopeKind rather than the
|
||||||
// indistinguishable-from-a-typo UnresolvedScope (Gitea #533); this arm stays as the answer
|
// indistinguishable-from-a-typo UnresolvedScope; this arm stays as the answer
|
||||||
// for a direct ResolveOne caller.
|
// for a direct ResolveOne caller.
|
||||||
case NodeAclScopeKind.Cluster:
|
case NodeAclScopeKind.Cluster:
|
||||||
case NodeAclScopeKind.FolderSegment:
|
case NodeAclScopeKind.FolderSegment:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
|
/// <summary>Create / list / revoke OPC UA data-plane API keys.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
|
/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a
|
||||||
/// page can reach is untestable, and minting credentials is not where that belongs.
|
/// page can reach is untestable, and minting credentials is not where that belongs.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
|
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings. The data plane is
|
||||||
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ApiKeyOptions
|
public sealed class ApiKeyOptions
|
||||||
@@ -34,7 +34,7 @@ public sealed class ApiKeyOptions
|
|||||||
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
|
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>0</c> or negative disables the deadline, restoring the pre-R3-05 unbounded behaviour. Do not
|
/// <c>0</c> or negative disables the deadline, restoring the previous unbounded behaviour. Do not
|
||||||
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
|
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
|
||||||
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
|
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
|
||||||
/// in the server, not just its own.
|
/// in the server, not just its own.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure helpers for the OPC UA data-plane API key format <c>otua_<keyId>_<secret></c>
|
/// Pure helpers for the OPC UA data-plane API key format <c>otua_<keyId>_<secret></c>
|
||||||
/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the
|
/// — generation, parsing, and constant-time verification. No I/O and no DI, so the
|
||||||
/// security-critical half is unit-testable on its own.
|
/// security-critical half is unit-testable on its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The data-plane authorization inputs a node is currently serving: the API keys that may
|
/// The data-plane authorization inputs a node is currently serving: the API keys that may
|
||||||
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
|
/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the
|
||||||
/// generation those were resolved at (#520, stage 4).
|
/// generation those were resolved at.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -215,7 +215,6 @@ public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
|
|||||||
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
|
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>Never raised — this source is permanently empty, so nothing derived from it can go stale.</remarks>
|
|
||||||
public event Action<DataPlaneAuthoritySnapshot>? Changed
|
public event Action<DataPlaneAuthoritySnapshot>? Changed
|
||||||
{
|
{
|
||||||
add { }
|
add { }
|
||||||
@@ -252,7 +251,7 @@ public interface IDataPlaneAuthoritySink
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b> (#520 stage 5).
|
/// <b>It owns the <see cref="PermissionTrieCache"/> rather than being handed one</b>.
|
||||||
/// The trie is built from the same grants and the same generation as the snapshot, inside the same
|
/// The trie is built from the same grants and the same generation as the snapshot, inside the same
|
||||||
/// <see cref="Apply"/> call, so the two cannot describe different deployments. Injecting the cache
|
/// <see cref="Apply"/> call, so the two cannot describe different deployments. Injecting the cache
|
||||||
/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has
|
/// would have made "install the snapshot" and "install the trie" two wirings, and this repo has
|
||||||
@@ -282,7 +281,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
|
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Raised after <see cref="Apply"/> publishes a new snapshot.</summary>
|
||||||
public event Action<DataPlaneAuthoritySnapshot>? Changed;
|
public event Action<DataPlaneAuthoritySnapshot>? Changed;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
|
/// The hierarchy is <c>Cluster → Realm → …node path…</c>. The node path is <b>variable length</b>,
|
||||||
/// which is the whole reason this type is no longer a fixed set of level properties (#520 stage 3):
|
/// which is the whole reason this type is no longer a fixed set of level properties:
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>
|
/// <item>
|
||||||
@@ -57,7 +57,7 @@ public enum NodeHierarchyKind
|
|||||||
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
|
/// <summary><c>Area / Line / Equipment [/ EffectiveName]</c> — the UNS realm.</summary>
|
||||||
Uns,
|
Uns,
|
||||||
|
|
||||||
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended, #520 stage 3.)</summary>
|
/// <summary><c>folder… / Driver / Device / group… / Tag</c> — the raw device-tree realm. (Appended.)</summary>
|
||||||
Raw,
|
Raw,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ public enum NodeHierarchyKind
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
|
/// This exists because v3 <b>has no <c>Namespace</c> entity</b>. The snapshot was retired when the
|
||||||
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
|
/// address space went dual-namespace ("the two OPC UA namespaces are implicit"), which left the
|
||||||
/// trie's level-1 gate with no configured key source at all — the pre-stage-3 walker read a
|
/// trie's level-1 gate with no configured key source at all — the earlier walker read a
|
||||||
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
|
/// <c>NamespaceId</c> that nothing in the system ever produced, so the level could only ever fail
|
||||||
/// to match. Deriving the segment from the realm restores the level as something real without
|
/// to match. Deriving the segment from the realm restores the level as something real without
|
||||||
/// reintroducing an entity.
|
/// reintroducing an entity.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
|
/// <summary>Reserved principal names for the OPC UA data plane.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
|
/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server
|
||||||
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
|
/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public sealed class PermissionTrieNode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
|
/// ⚠️ The comparer is <b>case-sensitive ordinal</b>, matching <c>RawPaths.Comparer</c> and OPC UA
|
||||||
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> before #520 stage 3, which was a silent
|
/// NodeId identity. It was <c>OrdinalIgnoreCase</c> in an earlier version, which was a silent
|
||||||
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
|
/// widening: raw sibling names are unique under an ordinal comparer, so <c>Speed</c> and
|
||||||
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
|
/// <c>speed</c> can both exist under one device, and a grant on either would have matched both.
|
||||||
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
|
/// Case-sensitive matching is also the fail-closed direction — a case-drifted ACL row now grants
|
||||||
@@ -167,7 +167,7 @@ public sealed class PermissionTrieNode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Compared <c>OrdinalIgnoreCase</c>: this holds principal names, which are identifiers, and the
|
/// Compared <c>OrdinalIgnoreCase</c>: this holds principal names, which are identifiers, and the
|
||||||
/// repo's rule (#533) is that identifiers compare case-insensitively while <em>path segments</em>
|
/// repo's rule is that identifiers compare case-insensitively while <em>path segments</em>
|
||||||
/// — the <see cref="Children"/> keys — compare ordinally.
|
/// — the <see cref="Children"/> keys — compare ordinally.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public HashSet<string> BrowseTransitPrincipals { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> BrowseTransitPrincipals { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
|
/// life of the cache entry. Idempotent — two builds from the same rows produce equal tries.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Before #520 stage 3 a null
|
/// ⚠️ <b>There is no longer a "deterministic test mode".</b> Previously a null
|
||||||
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
|
/// <c>scopePaths</c> made sub-cluster rows fall back to a direct child of the trie
|
||||||
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
|
/// root keyed on <c>ScopeId</c>. That position is unreachable by construction — level 1 of the walk
|
||||||
/// is the realm — so the fallback produced grants that looked placed and could never match, and
|
/// is the realm — so the fallback produced grants that looked placed and could never match, and
|
||||||
@@ -183,7 +183,7 @@ public enum PermissionTrieBuildDiagnosticReason
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The row's <see cref="NodeAclScopeKind"/> is retired and has no v3 entity behind it
|
/// The row's <see cref="NodeAclScopeKind"/> is retired and has no v3 entity behind it
|
||||||
/// (<see cref="NodeAclScopeKind.FolderSegment"/>), so it can never resolve whatever its
|
/// (<see cref="NodeAclScopeKind.FolderSegment"/>), so it can never resolve whatever its
|
||||||
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> by Gitea #533: the two have
|
/// <c>ScopeId</c>. Split out of <see cref="UnresolvedScope"/> deliberately: the two have
|
||||||
/// different remedies — re-scope the grant to <see cref="NodeAclScopeKind.RawFolder"/> versus
|
/// different remedies — re-scope the grant to <see cref="NodeAclScopeKind.RawFolder"/> versus
|
||||||
/// fix an id — and conflating them told an operator with a legacy row to go looking for a typo.
|
/// fix an id — and conflating them told an operator with a legacy row to go looking for a typo.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// ⚠️ <b>The refresh machinery below is LDAP-shaped and is largely vestigial under the
|
/// ⚠️ <b>The refresh machinery below is LDAP-shaped and is largely vestigial under the
|
||||||
/// API-key model (#520).</b> It exists because an LDAP group membership can change
|
/// API-key model.</b> It exists because an LDAP group membership can change
|
||||||
/// out-of-band in the directory, so a long-lived session had to re-resolve periodically.
|
/// out-of-band in the directory, so a long-lived session had to re-resolve periodically.
|
||||||
/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the
|
/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the
|
||||||
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
|
/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public static class EquipmentNodeWalker
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
|
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
|
||||||
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
|
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
|
||||||
/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not
|
/// are materialized by the dual-namespace fan-out, not by this walker, so they are not
|
||||||
/// part of this snapshot.
|
/// part of this snapshot.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record EquipmentNamespaceContent(
|
public sealed record EquipmentNamespaceContent(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public static class IdentificationFolderBuilder
|
|||||||
public const string FolderName = "Identification";
|
public const string FolderName = "Identification";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
|
/// Canonical field set exposed in the Identification sub-folder. Order
|
||||||
/// matches the decision-log entry so any browse-order reader can cross-reference
|
/// matches the decision-log entry so any browse-order reader can cross-reference
|
||||||
/// without re-sorting.
|
/// without re-sorting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public sealed class CapabilityInvoker : IDriverCapabilityInvoker
|
|||||||
{
|
{
|
||||||
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
|
// Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are
|
||||||
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
|
// immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child,
|
||||||
// which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is
|
// which constructs a fresh invoker (respawn-on-change) — so lazily caching the snapshot is
|
||||||
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
|
// safe and removes the per-write allocation the previous once-per-call snapshot incurred. The
|
||||||
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
|
// null-check-assign tolerates a benign first-call race (both threads build an equivalent record).
|
||||||
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
|
var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions();
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
|
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
|
||||||
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
|
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
|
||||||
// Reinstate first: an instance id removed by an earlier deployment (Release ⇒ Retire) and now re-added
|
// Reinstate first: an instance id removed by an earlier deployment (Release ⇒ Retire) and now re-added
|
||||||
// must record again (#609).
|
// must record again.
|
||||||
_statusTracker.Reinstate(driverInstanceId);
|
_statusTracker.Reinstate(driverInstanceId);
|
||||||
_statusTracker.Invalidate(driverInstanceId);
|
_statusTracker.Invalidate(driverInstanceId);
|
||||||
|
|
||||||
@@ -94,17 +94,15 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
|
|||||||
optionsGeneration: generation);
|
optionsGeneration: generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Without this the tracker kept a removed instance's snapshots forever (only Create
|
||||||
|
// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
|
||||||
|
// them every tick — undoing the host's telemetry-hub eviction within one interval.
|
||||||
|
// The tracker retires the instance rather than merely invalidating it: the host stops the
|
||||||
|
// child asynchronously, so a capability call it still had in flight completes through the released
|
||||||
|
// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
|
||||||
|
// child's in-flight SubscribeBulk). A retired id ignores those writes until a later Create
|
||||||
|
// for the same id reinstates it.
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// #609: without this the tracker kept a removed instance's snapshots forever (only <see cref="Create"/>
|
|
||||||
/// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
|
|
||||||
/// them every tick — undoing the host's telemetry-hub eviction within one interval.
|
|
||||||
/// <para>The tracker <b>retires</b> the instance rather than merely invalidating it: the host stops the
|
|
||||||
/// child asynchronously, so a capability call it still had in flight completes through the released
|
|
||||||
/// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
|
|
||||||
/// child's in-flight SubscribeBulk). A retired id ignores those writes until a later <see cref="Create"/>
|
|
||||||
/// for the same id reinstates it.</para>
|
|
||||||
/// </remarks>
|
|
||||||
public void Release(string driverInstanceId)
|
public void Release(string driverInstanceId)
|
||||||
{
|
{
|
||||||
_builder.Invalidate(driverInstanceId);
|
_builder.Invalidate(driverInstanceId);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public sealed record DriverResilienceOptions
|
|||||||
public IReadOnlyDictionary<DriverCapability, CapabilityPolicy> CapabilityPolicies { get; init; }
|
public IReadOnlyDictionary<DriverCapability, CapabilityPolicy> CapabilityPolicies { get; init; }
|
||||||
= new Dictionary<DriverCapability, CapabilityPolicy>();
|
= new Dictionary<DriverCapability, CapabilityPolicy>();
|
||||||
|
|
||||||
// RecycleIntervalSeconds is GONE (Gitea #522). It configured a scheduled recycle of a Tier C
|
// RecycleIntervalSeconds is GONE. It configured a scheduled recycle of a Tier C
|
||||||
// driver's out-of-process Host — a process that no longer exists anywhere: Galaxy reaches MXAccess
|
// driver's out-of-process Host — a process that no longer exists anywhere: Galaxy reaches MXAccess
|
||||||
// over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process Host/Proxy/Shared
|
// over gRPC to the external mxaccessgw sidecar (PR 7.2 retired the in-process Host/Proxy/Shared
|
||||||
// projects) and FOCAS has run in-process since its managed wire client landed 2026-04-24. The
|
// projects) and FOCAS has run in-process since its managed wire client landed 2026-04-24. The
|
||||||
@@ -64,7 +64,7 @@ public sealed record DriverResilienceOptions
|
|||||||
/// Per-tier per-capability default policy table, per the Phase 6.1
|
/// Per-tier per-capability default policy table, per the Phase 6.1
|
||||||
/// Stream A.2 specification. The parser enforces no-retry on
|
/// Stream A.2 specification. The parser enforces no-retry on
|
||||||
/// <see cref="DriverCapability.Write"/> and <see cref="DriverCapability.AlarmAcknowledge"/>
|
/// <see cref="DriverCapability.Write"/> and <see cref="DriverCapability.AlarmAcknowledge"/>
|
||||||
/// as an invariant (R2-02/S-8) — a JSON retryCount override on those capabilities is forced back
|
/// as an invariant — a JSON retryCount override on those capabilities is forced back
|
||||||
/// to 0; per-tag opt-in via <see cref="WriteIdempotentAttribute"/> is the future relaxation.
|
/// to 0; per-tag opt-in via <see cref="WriteIdempotentAttribute"/> is the future relaxation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tier">The driver tier to get defaults for.</param>
|
/// <param name="tier">The driver tier to get defaults for.</param>
|
||||||
@@ -84,7 +84,7 @@ public sealed record DriverResilienceOptions
|
|||||||
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
|
// Initialize: no retry (the DriverInstanceActor reconnect timer is the retry loop) and a
|
||||||
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
|
// threshold of 2, NOT the Read-sized 5 — the breaker samples over 30 s and the reconnect
|
||||||
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
|
// timer fires every 10 s, so at most three init attempts fit one window; a threshold of 5
|
||||||
// could never open on connect failures (R2-10 T10).
|
// could never open on connect failures.
|
||||||
[DriverCapability.Initialize] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 2),
|
[DriverCapability.Initialize] = new(TimeoutSeconds: 5, RetryCount: 0, BreakerFailureThreshold: 2),
|
||||||
},
|
},
|
||||||
DriverTier.B => new Dictionary<DriverCapability, CapabilityPolicy>
|
DriverTier.B => new Dictionary<DriverCapability, CapabilityPolicy>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public static class DriverResilienceOptionsParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No recycle-interval handling: the scheduled-recycle machinery was deleted in Gitea #522
|
// No recycle-interval handling: the scheduled-recycle machinery was deleted
|
||||||
// (nothing constructed it, and IDriverSupervisor — which would have performed the recycle —
|
// (nothing constructed it, and IDriverSupervisor — which would have performed the recycle —
|
||||||
// had no implementations). A "recycleIntervalSeconds" key surviving in an existing
|
// had no implementations). A "recycleIntervalSeconds" key surviving in an existing
|
||||||
// ResilienceConfig blob is harmless: the shape no longer declares it, and unknown JSON
|
// ResilienceConfig blob is harmless: the shape no longer declares it, and unknown JSON
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|||||||
/// Pure in-memory (no EF Core / SQL Server dependency) so tests can exercise it directly.
|
/// Pure in-memory (no EF Core / SQL Server dependency) so tests can exercise it directly.
|
||||||
/// The operator-facing reader is the DPS publisher named above, mirroring the driver-health
|
/// The operator-facing reader is the DPS publisher named above, mirroring the driver-health
|
||||||
/// flow — not the DB-table/HostedService shape originally sketched for Phase 6.1 Stream E
|
/// flow — not the DB-table/HostedService shape originally sketched for Phase 6.1 Stream E
|
||||||
/// (R2-10 built the DPS reader instead).
|
/// (the DPS reader was built instead).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DriverResilienceStatusTracker
|
public sealed class DriverResilienceStatusTracker
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<StatusKey, ResilienceStatusSnapshot> _status = new();
|
private readonly ConcurrentDictionary<StatusKey, ResilienceStatusSnapshot> _status = new();
|
||||||
|
|
||||||
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated (#609). Every
|
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated. Every
|
||||||
// Record* for one of these is ignored: the removed instance's child stops ASYNCHRONOUSLY, so a capability
|
// Record* for one of these is ignored: the removed instance's child stops ASYNCHRONOUSLY, so a capability
|
||||||
// call it still had in flight completes through its invoker after the host's release and would otherwise
|
// call it still had in flight completes through its invoker after the host's release and would otherwise
|
||||||
// re-create the entry — which the resilience publisher then re-emits every tick, forever. One entry per
|
// re-create the entry — which the resilience publisher then re-emits every tick, forever. One entry per
|
||||||
@@ -127,7 +127,7 @@ public sealed class DriverResilienceStatusTracker
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drop every snapshot for a driver instance that has been REMOVED from the deployed plan, and ignore
|
/// Drop every snapshot for a driver instance that has been REMOVED from the deployed plan, and ignore
|
||||||
/// every later write for it until <see cref="Reinstate"/> (#609). Called by
|
/// every later write for it until <see cref="Reinstate"/>. Called by
|
||||||
/// <c>DriverCapabilityInvokerFactory.Release</c>. Unlike <see cref="Invalidate"/> (a respawn, whose new
|
/// <c>DriverCapabilityInvokerFactory.Release</c>. Unlike <see cref="Invalidate"/> (a respawn, whose new
|
||||||
/// invoker records straight away), the removed instance's child is still stopping — a call it had in
|
/// invoker records straight away), the removed instance's child is still stopping — a call it had in
|
||||||
/// flight finishes after this returns, and without the hold its RecordCallStart / RecordSuccess /
|
/// flight finishes after this returns, and without the hold its RecordCallStart / RecordSuccess /
|
||||||
@@ -261,7 +261,7 @@ public sealed record ResilienceStatusSnapshot
|
|||||||
/// <c>driver-resilience-status</c> telemetry channel.
|
/// <c>driver-resilience-status</c> telemetry channel.
|
||||||
/// <para>It used to say this fed <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c> "until
|
/// <para>It used to say this fed <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c> "until
|
||||||
/// the full telemetry observer lands". The telemetry observer landed (Phase 5); that table never
|
/// the full telemetry observer lands". The telemetry observer landed (Phase 5); that table never
|
||||||
/// had a writer and was dropped in Gitea #524.</para>
|
/// had a writer and was since dropped.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int CurrentInFlight { get; init; }
|
public int CurrentInFlight { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public sealed class ProbeCommand : S7CommandBase
|
|||||||
|
|
||||||
// Derive the headline verdict from BOTH the driver state AND the probe-read
|
// Derive the headline verdict from BOTH the driver state AND the probe-read
|
||||||
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
|
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
|
||||||
// line (Driver.S7.Cli-012; mirrors Modbus.Cli's ComputeVerdict).
|
// line (mirrors Modbus.Cli's ComputeVerdict).
|
||||||
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
|
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user