Compare commits

...

15 Commits

Author SHA1 Message Date
Joseph Doherty 4cc0215bb4 fix(adminui): the cluster-node editor was dead — 500 on load, silent no-op on save
Both defects are PRE-EXISTING (present on master, unrelated to #499) and were found
live-verifying the #499 guard, which lives on this page and was unreachable without
them fixed.

1. **HTTP 500 on load.** `FormModel.ServiceLevelBase` was `byte` to match the
   entity, and `InputNumber<T>`'s static constructor throws outright for
   `System.Byte` — "The type 'System.Byte' is not a supported numeric type". That
   escapes `BuildRenderTree` unhandled, which in Blazor takes the WHOLE page down,
   not one field. `/clusters/{id}/nodes/{nodeId}` and `.../nodes/new` have never
   rendered. Now `int` with the same `[Range(0, 255)]` and a cast on the way to the
   entity, so the stored type is unchanged. (Same failure mode as #504: an
   unhandled throw inside the render tree is a page-kill.)

2. **Save did nothing, silently.** `NodeId` was validated against
   `^[A-Za-z0-9_-]+$`, which forbids the colon — but every NodeId in this system is
   `host:port` (`ClusterRoleInfo` and `ConfigPublishCoordinator` derive it as
   `member.Address.Host:Port`, the seed writes `central-1:4053`, and
   `NodeDeploymentState.NodeId` is FK-bound to it). So every existing node failed
   validation, `OnValidSubmit` never ran, and the button was inert. Pattern now
   allows `:` and `.` (hostnames may be dotted).

3. **Added the missing `<ValidationSummary/>`.** The form had a
   `DataAnnotationsValidator` but nothing rendering its output, so a validation
   failure produced no feedback at all — which is precisely how (2) stayed
   invisible. This is the change that stops the next such defect being silent.

Live-verified on docker-dev (both central nodes rebuilt, since :9200 round-robins
the pair): page 500 → 200 on both routes, and a real edit now persists.
2026-07-26 10:42:04 -04:00
Joseph Doherty 4b9bcddbcc feat(drivers): periodic reconcile of desired vs actual subscriptions (#488)
Option A from the issue — the cheap consistency check, no interface change.

The desired ref set was re-applied on a Connected TRANSITION (`ResubscribeDesired`)
and on a deploy's `SetDesiredSubscriptions`, and nowhere else. A subscription lost
while the driver STAYED Connected — a subscribe that failed and was never retried,
a handle dropped down an error path — left the actor subscribed to nothing while
still reporting Healthy, indefinitely.

`ReconcileSubscription` now rides the existing 30 s `health-poll` tick: while
Connected, a driver holding a desired set but no live handle is re-subscribed.

Gated on `ISubscribable`. A driver that cannot subscribe at all can still be handed
a desired set, and without the guard it would be told `Subscribe` every tick and
fail every tick, forever.

This is a state-machine consistency check, NOT a probe — it can only see that this
actor holds no handle, never that a handle is present but dead server-side, because
`ISubscriptionHandle` is opaque. That is option B, and it stays deferred until a
real occurrence shows the handle outliving the subscription; it would need a
liveness member implemented across all eight drivers with different subscription
mechanics.

A redundant `Subscribe` is possible (a tick queued ahead of an already-queued one
observes the same no-handle state) and is harmless: `HandleSubscribeAsync` is a
`ReceiveAsync`, so no tick is processed mid-subscribe, and it drops any prior
subscription before establishing the new one. Suppressing it would need a
pending-subscribe flag threaded through every self-tell site — more state than the
race is worth.

`healthPollInterval` becomes an optional Props parameter, mirroring the existing
`rediscoverInterval` seam, so a test can drive the reconcile without waiting 30 s.

Three tests. The reconcile itself is proven by a stub whose FIRST subscribe throws:
nothing else would ever retry it, so a second attempt can only come from the
reconcile — disabling `ReconcileSubscription` turns it red. The two guard tests are
absence assertions and are bounded by a positive control rather than a sleep: a
counting health publisher makes ticks PROCESSED observable, so "still one subscribe"
means the reconcile ran and declined, not that the timer had not fired.

Explicitly not deploy-time drift (#486) — re-asserting an already-correct desired
set would not have helped there.
2026-07-26 10:30:19 -04:00
Joseph Doherty 2193d9f2e4 fix(config): refuse to store a Sql credential in a node config override (#499)
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config
is persisted on, and the only one nothing gated. It is a map keyed by
`DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal
`connectionString` pasted there leaks exactly as one pasted into the driver — the
leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`.

Gated at the SAVE, not at the deploy — a deliberate departure from both options the
issue offered:

- `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the
  snapshot and every builder of one for a single rule about a value that never
  enters the artifact.
- More to the point, a deploy gate is the wrong instrument here. Node overrides are
  not in the artifact, so blocking a deploy would not stop the credential being
  stored — it is already in the database and replicated by then. Refusing the save
  is the only point where "refuse to store it" is literally true, which is #498's
  own framing: discarding a secret on read is not the same as refusing to store it.

The check moves into a shared `SqlCredentialGuard` so the two enforcement points
cannot drift; `DraftValidator` now calls it instead of its own private helper.

Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for
instance ids that ARE Sql drivers. The issue asked whether to generalise to
credential-shaped keys across every driver type; not doing that, for the same
reason #498 did not: a broader sweep would start refusing configs that are
legitimate today for drivers which never made an indirect-credential guarantee,
which is a regression rather than defence in depth. Widen per driver, as each gains
its own contract.

The error names the offending driver instance(s) and NEVER the value — it reaches
the AdminUI and the audit trail.

14 new cases cover both halves, including the case variants (System.Text.Json binds
`ConnectionString` to `connectionString`, so a case variant is the same key, not a
bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
2026-07-26 10:30:04 -04:00
Joseph Doherty 8b7ea3213d fix(rig,docs): backfill ClusterNode.GrpcPort and document the upgrade step (#493)
`GrpcPort` is a nullable column added by Phase 5. Every insert in the docker-dev
seed is INSERT-if-not-exists — the right shape for a re-runnable seed, but it means
a new nullable column never reaches rows an earlier version of the file created. On
a persisted volume all six rows kept NULL, central found no dial targets, and
`TelemetryDial:Mode=Grpc` connected to nothing. (`AkkaPort` escaped this only
because it is NOT NULL with a default of 4053.)

Seed now backfills `GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'` to 4056.
NULL only — it must not overwrite a port an operator set deliberately on a
long-lived dev volume, and NULL is the unambiguous "predates the column" marker.

Deliberately NOT doing the suggested EF data migration for real deployments. The
correct value is each node's own `Telemetry:GrpcListenPort`, which is
per-deployment configuration the database cannot derive; a migration could only
invent one, and a WRONG dial target is worse than the NULL the dialer already skips
explicitly with a throttled warning. The rig can be backfilled because there the
port is fixed by docker-compose.yml and therefore actually knowable.

`docs/Telemetry.md` gains an upgrade section stating the prerequisite, the symptoms
(the throttled skip log, LISTEN with no ESTABLISHED, the pill never going live) and
the UPDATE to run before flipping any node to Grpc — plus why there is no migration.
2026-07-26 10:29:51 -04:00
Joseph Doherty 240d7aa8aa fix(test): give the three absence assertions a positive control (#501)
`AwaitAssert` returns on its FIRST success, so an assertion that is already true at
t=0 passes instantly and spends none of its duration. All three sites asserted an
absence against a collection that starts empty, so none of them ever gave the
handler the time their comments claimed.

Replaced with mailbox-ordering positive controls rather than sleeps:

- `RouteNativeAlarmAck_unknown_node_is_dropped` — follow the unmapped ack with a
  MAPPED one. The host forwards via `Tell` and the child handles `RouteAlarmAck`
  with `ReceiveAsync` (which suspends its own mailbox), so once the control reaches
  the driver the unmapped one has provably been handled.
- `RouteNativeAlarmAck_on_non_primary_is_dropped` — the role itself is the control:
  return the node to Primary and re-send. Comments (`gated` / `control`) are the
  discriminator, so a leaked ack is visible rather than merged into a count.
- `PeerProbeSupervisorTests.Single_node_snapshot_spawns_no_children` — a FOURTH site
  the issue did not list. It says the other `ChildCount.ShouldBe(0)` waits are each
  preceded by a `ShouldBe(1)`; that is true of three of the four, but not this one,
  which asserts the initial state. Now followed by a two-node snapshot: the count
  reaching 1 proves the single-node snapshot was processed, and a local-node child
  would have made it 2.

The issue warned these may turn red, which would be a real defect surfacing. They
did not — the routing is correct. Proven by breaking it deliberately instead:
disabling the Primary gate reddens the Secondary test, and routing unmapped ids to
an arbitrary mapping reddens the unknown-node test. Under the old form both breaks
passed.
2026-07-26 10:29:41 -04:00
Joseph Doherty 95295210b0 fix(test): scope the EventPump counter capture to its own pump (#503)
Root cause was cross-test measurement leakage, not a timing budget.

EventPump's `Meter` is STATIC — one instance per process — and a `MeterListener`
can only select by *meter* in `InstrumentPublished`. `StartMeterCapture` therefore
received measurements from every `EventPump` alive anywhere in the assembly. xUnit
runs test classes in parallel and several build pumps (`EventPumpStreamFaultTests`,
the `Driver-X` test in this same file), so a sibling's events landed in these
counters. That is why the flake needed a busy box: it needed two tests to overlap.
Measured directly during the gate — `Received = 11` in a run that emitted 10.

The old `Received == Dispatched + Dropped + InFlight` assertion is what turned the
leak into a failure. `InFlight` is DERIVED as `Received - Dispatched - Dropped`, so
the identity is a tautology that cannot fail on a real defect — only on a foreign
increment landing between its four separate `Interlocked.Read` calls. Hence the
reported subject, `counters.Received`. It is deleted, along with the `InFlight`
member, and replaced by direct assertions on the three MEASURED counters.

Three further defects in the same test, all found on the way:

- **The "held" dispatch loop was never held.** `OnDataChange` is `EventHandler<T>`
  — void-returning — so `async (_, _) => await gate` is async VOID: it returned to
  the dispatch loop at the first await and blocked nothing. Measured `Dispatched =
  2..3`, never 0. Drops happened only when the producer happened to outrun the
  consumer, so the arrangement the doc comment describes was fiction and the
  assertions rode on scheduling luck. Now a synchronous `ManualResetEventSlim.Wait()`
  on the loop's own thread, pinned by `Dispatched.ShouldBe(0)`.
- **Fixed `Task.Delay(150)`** replaced by a presence poll on the drop count (#500's
  rule). Dropped starts at 0, so it cannot be satisfied by the initial state.
- **Emission is staged** — one event, wait for the dispatcher to enter the handler,
  then the other nine — so the arithmetic is exact (`10 - 1 held - 2 buffered = 7`)
  rather than scheduler-dependent.

The gate release moved into `finally`, ahead of disposal: `DisposeAsync` awaits the
dispatch loop, which is now genuinely parked on that gate, so a failed assertion
would otherwise hang instead of failing.

Verified: 15/15 clean under the exact contention the issue measured (Runtime +
AdminUI + Core concurrently) — where the half-fixed version still failed on run 4.
Falsifiability: restoring the async-void handler turns the new assertions red.
2026-07-26 10:29:27 -04:00
Joseph Doherty e3155fbbf5 merge: never slice a DB-sourced string with the range operator (#504)
v2-ci / build (push) Successful in 4m11s
v2-ci / unit-tests (push) Failing after 14m22s
`@s.SourceHash[..12]` on an unvalidated nvarchar(64) threw out of
BuildRenderTree and took the whole /scripts page to an HTTP 500. Replaced with a
shared, null/short-safe `DisplayText.Abbreviate`, applied to all five unguarded
range-operator slices over DB-sourced strings in the AdminUI.

Live-verified on docker-dev: /scripts 500 -> 200 with the short hashes rendered
in full; long hashes still truncate with an ellipsis on the other pages.
2026-07-26 09:43:55 -04:00
Joseph Doherty 47d148daf9 fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.

New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
  display never implies text that isn't there (the old markup appended "…"
  unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.

Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:

  Scripts.razor                    SourceHash    (the reported crash)
  Certificates.razor               Thumbprint    (read off the on-disk store)
  Deployments.razor  x2            RevisionHash
  Clusters/ClusterOverview.razor   RevisionHash

The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.

NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.

Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.

Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
2026-07-26 09:43:48 -04:00
Joseph Doherty 755ae1aa3f merge: re-assert non-normal scripted-alarm conditions after a (re)load (#487)
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 3h14m40s
Closes the redeploy-reset gap on Part 9 scripted-alarm condition nodes — the
alarm analogue of the VirtualTag ReassertValue fix.

A full address-space rebuild re-materialises every condition node fresh, while
the engine's reload produces EmissionKind.None for an alarm whose state did not
change — so nothing wrote the node. Live-reproduced on docker-dev: a cleared-
but-still-UNACKNOWLEDGED alarm came back reporting acknowledged, dropping Retain
to false, so it vanished from ConditionRefresh entirely. The operator's
outstanding alarm silently disappeared from the alarm list on deploy.

ScriptedAlarmHostActor.ReassertConditionNodes now writes one AlarmStateUpdate
per alarm not in the Part 9 no-event position, sourced from the new
ScriptedAlarmEngine.GetProjections(). Node-only by construction — the projection
type carries no EmissionKind, so it cannot become an alerts row or a duplicate
historian record.

Live gate PASSED (pre-fix image vs. rebuilt, central-2): condition absent
pre-fix, present + Unacknowledged + Retain=True post-fix, stamped with its own
LastTransitionUtc; /alerts empty across two re-asserts with the panel proven
live by a real ACTIVATED transition.
2026-07-26 09:18:23 -04:00
Joseph Doherty 549c656489 fix(alarms): re-assert non-normal scripted-alarm conditions after a (re)load (#487)
A deploy that triggers a FULL address-space rebuild clears `_alarmConditions`
and re-materialises every condition node fresh — inactive, acked, confirmed,
unshelved. The engine reloads its persisted state and re-derives Active from the
predicate, so it ends up correct; but there is no transition to report, so
`LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it. Nothing
writes the node.

Live-reproduced on the docker-dev rig against the pre-fix image: an alarm that
had fired and cleared but was still UNACKNOWLEDGED came back from a rebuild
reporting Acknowledged with Retain=false — so it disappeared from
ConditionRefresh entirely, while the engine still held Unacknowledged. An
operator's outstanding alarm silently vanishes from the alarm list on deploy.

`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load it
reads the new `ScriptedAlarmEngine.GetProjections()` and sends one
`AlarmStateUpdate` per alarm NOT in the Part 9 no-event position.

Load-bearing properties:

- Node-only. It sends `AlarmStateUpdate` and nothing else — never the `alerts`
  topic, never the telemetry hub. `alerts` is the historization path, so a row
  per alarm per deploy would append a duplicate historian/AVEVA record every
  time anyone deploys. This is why it reads a projection rather than re-emitting:
  `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for
  the emission machinery — or a future refactor — to mistake for a transition.
  The issue's sketch said `GetState(alarmId)` + `LoadedAlarmIds`; that alone
  would have clobbered the node's severity and message, so the projection also
  carries the definition's severity, the resolved message template, and the
  last-observed worst-input quality.
- Only non-normal alarms. One in the no-event position already matches what the
  materialise built, so a never-fired alarm stays byte-identical to a deploy
  without this pass (including its Message, which the materialise seeds with the
  display name), and an all-normal deploy writes nothing at all.
- Timestamp is the condition's own `LastTransitionUtc`, not the deploy instant.
- No duplicate Part 9 events: `WriteAlarmCondition`'s delta-gate compares against
  the node's CURRENT state, so a re-assert onto a freshly materialised node is a
  genuine delta (fires once — correct, the rebuild reset what clients see) while
  one onto a surgically-preserved node is suppressed.

Tests: 6 new host tests + 6 new engine tests. Falsifiability checked by
disabling the call — 4 of the 6 host tests go red; the other 2 are absence
guards against an over-broad fix and pass either way by design.

Live gate (docker-dev, central-2, pre-fix image vs. rebuilt image):
- pre-fix: condition absent from ConditionRefresh after a rebuild;
- post-fix: present, Unacknowledged, Retain=True, stamped with its own
  LastTransitionUtc rather than the restart instant;
- /alerts stayed empty across two re-asserts, with the panel proven live by a
  real ACTIVATED transition immediately afterwards — no duplicate history.
2026-07-26 09:06:39 -04:00
Joseph Doherty 28c2866710 merge: Sql driver follow-ups #496/#497/#498 + the Runtime.Tests flake #500 (fix/sql-driver-followups)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 15m14s
Four commits closing the three follow-ups the Sql poll driver left behind, plus the
intermittent Runtime.Tests failure found while verifying them.

- #497 corrects 16 wrong OPC UA status-code constants across 6 drivers (the issue named
  3) and adds StatusCodeParityTests, a reflection guard checking every driver's
  hard-coded uint against the pinned SDK. That guard is what found the other 13.
- #498 fails the deploy when a Sql driver's persisted config carries a literal
  connectionString — the write-side half of a guarantee only enforced on read.
- #496 implements the design 8.1 catalog gate: authored table/column names are resolved
  against the live catalog at Initialize and replaced with the catalog's own spelling, so
  quoting becomes the backstop it was documented to be rather than the sole defence.
- #500 fixes the Runtime.Tests flake: four ordering/logic defects plus a class of tight
  presence budgets, now routed through a documented RuntimeActorTestBase.PresenceBudget.

Verified: full solution builds, all 41 unit-test projects green, Sql integration suite
21/21 against the real SQL Server on 10.100.0.35, and 30 consecutive Runtime.Tests runs
clean against a measured 13% baseline.

Still open by design: #499 (ClusterNode.DriverConfigOverridesJson is a third config
persistence surface DraftValidator cannot see) and #501 (two alarm-ack tests use
AwaitAssert for an absence assertion, so they prove nothing; the rewrite may
legitimately turn them red).
2026-07-25 22:27:26 -04:00
Joseph Doherty 154171f48c test(runtime): fix the intermittent Runtime.Tests failure — 4 ordering/logic defects + the presence-budget class (#500)
The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test
name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it
was never ONE flaky test: five failures across three distinct tests in that batch, and
five more distinct tests over the verification rounds that followed.

Two hypotheses were tested and DISPROVED by measurement before anything was changed:

- Cluster-formation timeout. Every test in this assembly forms a real single-node Akka
  cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up
  — 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero
  timeouts. A 3.4x margin; not the cause.
- Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout.
  Not the cause.

FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout):

1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place
   The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and
   then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register,
   Unregister] interleaving LastSender is the DYING child and the assertion fails. Now
   captures the sender of the Register message as it arrives. Fully deterministic; no
   timing involved. (Swept the other four LastSender sites: all drain the Unregister
   first, so their ordering is fixed. Only this one was unsafe.)

2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath
   ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is
   still in Connecting, which deliberately fast-fails writes ("driver not connected"),
   and the NodeId->driver reverse map has not been pushed. Now retries until accepted.
   This does not weaken the assertion — every rejection branch replies WITHOUT reaching
   the driver, so Writes.Count.ShouldBe(1) still means exactly what it did.

3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_...
   One 500 ms constant served both presence budgets (AwaitAssert) and absence windows
   (ExpectNoMsg). Different quantities that happened to share a number, so the presence
   budget could not be raised without slowing every absence check. Split into
   AssertTimeout (5 s) and Settle (500 ms).

4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks
   Waited on `outbox == 0`, which is ALSO true before the first append — so the poll
   could win the race against the recorder and return having observed the INITIAL state,
   after which the CallCount>=2 check outside the block failed against a recorder that
   had not run. Both conditions are now polled together with the retry count as the
   discriminator. (The preceding test in the same file documents this exact trap.)

THE PRESENCE-BUDGET CLASS:

After those four, three consecutive 30-run rounds each still failed once — on a
DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s,
OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a
distribution, not a defect, and fixing it one test at a time was the wrong method.

RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s
AwaitAssert/AwaitCondition durations across four files route through it.

Why this is safe rather than sloppy: a presence budget is an upper bound before giving
up, not a wait — these helpers poll and return the instant the condition holds. Raising
one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass;
it only changes how quickly a real breakage reports. Absence windows are the opposite
(the elapsed time IS the assertion) and were deliberately left untouched with their own
short, individually-calibrated literals.

ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its
8 s budget was sized for message passing but actually waits on a REAL Roslyn compile
(ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold
script compiles are the slowest and most variable thing in the assembly, so that class
gets 30 s with the reason recorded.

Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13%
baseline. Full solution builds; all 41 unit-test projects green.

NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use
AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They
are excluded from the budget change on purpose — a bigger number does not fix them, and
rewriting them to settle-then-assert may legitimately turn them red.
2026-07-25 20:20:06 -04:00
Joseph Doherty 4dc2ad8084 feat(sql): implement the design §8.1 catalog identifier-validation gate (#496)
Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored
table/column names: an identifier went from the TagConfig blob straight into a
command text, bracket-quoted. The residual risk was bounded — a hostile name is
quoted into one nonexistent object, the query fails after the connection opens,
and the tag Bad-codes — but "bounded" is not "filtered", and the design promised
a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so.

SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against
the live catalog at Initialize, and REPLACE it with the catalog's own spelling.
The identifier text in an emitted poll query is therefore a string this driver
read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting
becomes the backstop it was documented to be.

Decisions worth knowing before touching this:

- Substitution, not just validation. Matching is exact-ordinal first, then a
  UNIQUE case-insensitive hit: SQL Server's default collation is CI so case
  variants have always worked, and rejecting them would break valid deployments.
  An ambiguous CI match under a case-sensitive collation is refused rather than
  guessed — picking one would publish another column's data under the operator's
  node, which is worse than rejecting the tag.

- Charset check BEFORE catalog lookup. Each identifier goes through
  QuoteIdentifier for its rejection rules first, so a name carrying a control or
  Unicode format character is rejected WITHOUT its value being echoed into a log
  line (Trojan-Source). A name that passes is safe to render, which is why
  catalog-miss messages do name it — an operator hunting a typo has to see what
  they wrote. Both halves are pinned by tests.

- A rejected tag keeps its node. It is dropped from the POLLED table but stays in
  the AUTHORED table, so it still materializes and reads BadNodeIdUnknown —
  §8.1's specified outcome. My first wiring dropped it from both, which deleted
  the node instead; an existing test (ReinitializeAsync_recoversFromFaulted)
  caught it. A status code can only be published by a node that exists, and a
  missing address-space entry is far harder to diagnose than a Bad quality.

- An unreadable catalog FAULTS Initialize; it does not reject every tag. That is
  the absence of evidence about the tags, not evidence against them — rejecting
  all of them would serve a confidently-empty address space and send the operator
  hunting typos that do not exist. Zero visible schemas is treated the same way,
  because that is exactly what a missing GRANT looks like. Faulting lands
  DriverInstanceActor in Reconnecting with its retry timer running.

- Bounded load: one schema list, one default-schema scalar, then one ListTables
  per distinct authored schema and one ListColumns per distinct authored relation
  — never a full catalog enumeration, and nothing after Initialize. Every
  authored name reaches the catalog queries as a bound @schema/@table parameter,
  so building the allow-list cannot itself be an injection vector.

- ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified
  `TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo
  would be a silent lie on any estate that maps service accounts to their own
  default schema. It is a query, not a constant, because the answer is
  per-connection.

- Accepted v1 limitation: a 3-part db.schema.table (or linked-server name)
  addresses a catalog this connection cannot enumerate, so it cannot be
  allow-listed and is rejected with a message pointing at the fix — expose the
  data through a view in the connected database.

VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and
reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a
second hand-rolled copy. (It also fixes a latent bug in the extracted code: the
OperationCanceledException arm detached the wrong task.)

Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite
catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that
suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA
path). Verified falsifiable: bypassing the gate turns exactly the three rejection
assertions red and leaves the rest green.

SqlInjectionRegressionTests is deliberately NOT rewritten to expect
BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the
quoting backstop on its own — defence in depth is only worth the name if each
layer holds independently. Rewriting those assertions would delete the backstop's
only coverage and leave the gate a single point of failure. Its scope note, which
said the gate does not exist, is updated to say why it stays where it is.
2026-07-25 16:07:53 -04:00
Joseph Doherty 1919a8e538 feat(config): reject a persisted Sql connectionString at the deploy gate (#498)
The Sql driver's "a pasted literal connection string is never persisted" guarantee was
enforced only on the READ path: SqlDriverConfigDto has no connectionString property and
UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being
WRITTEN. Config blobs are schemaless JSON columns and the fallback authoring pattern for
a driver without a typed form is a raw-JSON textarea, so an operator pasting
{"connectionString":"Server=...;Password=..."} would put a live credential in the
ConfigDb — persisted, replicated to every node's artifact cache, readable by anyone with
config access — while the runtime silently ignored it and the driver failed to connect.
Discarding a secret on read is not the same as refusing to store it.

DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy
(SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the
DeviceConfig of any device beneath it, carries a top-level connectionString key.

- Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the
  DTO sees it — a credential pasted into the device blob is the identical leak.
- The key is matched case-insensitively: System.Text.Json binds ConnectionString to a
  connectionString property by default, so an ordinal match would leave the obvious
  bypass open.
- Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and
  is not the mistake this rule exists to catch.
- The message never echoes the value — validation errors reach the AdminUI, the deploy
  log and the audit trail, and repeating the string there would leak the credential the
  rule is refusing to store. Pinned by a test.
- Malformed/blank/non-object JSON never throws: a parse failure would take down every
  other rule in the same pass.

16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail
and the 10 negatives stay green, so none of them passes vacuously.

Design §8.2's "if an inline connectionString is ever permitted (dev convenience only),
the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a
credential that has already been written.

Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third
persistence surface for a driver config blob, but it is not part of DraftSnapshot, so
this validator cannot see it.
2026-07-25 15:37:48 -04:00
Joseph Doherty b95b99ba8e fix(drivers): correct 16 wrong OPC UA status-code constants + guard them by reflection (#497)
Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by
reflection, not by copying siblings. All are client-visible: OPC UA clients branch on
status.

The three named in #497:
  - Historian.Gateway SampleMapper "BadNoData"  0x800E0000 -> 0x809B0000
    (0x800E0000 is BadServerHalted)
  - Galaxy "BadTimeout"                         0x800B0000 -> 0x800A0000
    (0x800B0000 is BadServiceUnsupported)
  - TwinCAT BadTypeMismatch                     0x80730000 -> 0x80740000
    (0x80730000 is BadWriteNotSupported)

BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS,
AbLegacy and AbCip carried the same 0x80730000. Every call site is a
FormatException/InvalidCastException conversion failure, so the name was right and
the value was wrong in all four.

Adding the reflection guard then surfaced nine further defects offline tests had
never checked:
  - Galaxy GoodLocalOverride  0x00D80000 -> 0x00960000  (not a UA code at all)
  - Galaxy UncertainLastUsableValue        0x40A40000 -> 0x40900000
  - Galaxy UncertainSensorNotAccurate      0x408D0000 -> 0x40930000
  - Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000
  - Galaxy UncertainSubNormal              0x408F0000 -> 0x40950000
  - TwinCAT BadOutOfService   0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported)
  - TwinCAT BadInvalidState   0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid)
  - AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent)
  - Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000

Galaxy's whole Uncertain block read as though the OPC DA quality byte could be
shifted into the UA substatus position; it cannot — the substatuses are an unrelated
enumeration. GatewayQualityMapper already held the correct table, which is what the
Galaxy values are now reconciled against.

Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project-
references every driver) reflects over every `const uint` in the deployed
ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it
equals Opc.Ua.StatusCodes.<name>. Discovery is by convention, so a new driver
assembly referenced by that project is covered with no edit here. It went red on all
nine defects above before the fixes and now checks 109 constants green.

Because reflection can only see a NAMED constant, two inline literals were hoisted so
the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and
GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline
`0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived.

The drivers stay SDK-free by design; only the test project references Opc.Ua.Core,
as the oracle.

Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests,
SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source),
a mislabelled comment in DriverInstanceActorTests, and stale constants in two design
docs, one of them the unexecuted MTConnect driver design that would have propagated
BadNoData's wrong value into a new driver.

The CLI's SnapshotFormatter value->name table was checked against the SDK and is
correct; no change needed.
2026-07-25 15:34:31 -04:00
64 changed files with 3877 additions and 253 deletions
+4
View File
@@ -77,6 +77,10 @@
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" /> <PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
hard-coded uint constants against. Referenced by that TEST project only — the drivers
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" /> <PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio <!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
+22
View File
@@ -106,6 +106,28 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed'); VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- Backfill: GrpcPort on rows that predate the column (#493)
--
-- Every insert above is INSERT-if-not-exists, which is the right shape for a re-runnable seed
-- but means a *new nullable* column never reaches rows created by an earlier version of this
-- file. On a persisted volume that is exactly what happened when Phase 5 added GrpcPort: the
-- six ClusterNode rows already existed, so they kept NULL, TelemetryNodeSource found no dial
-- targets, and TelemetryDial:Mode=Grpc silently connected to nothing (throttled Warning per
-- node — the code degrades gracefully, so nothing failed loudly).
--
-- Backfills NULL only. It must not overwrite a port an operator set deliberately on a
-- long-lived dev volume; a NULL is the unambiguous "this row predates the column" marker.
--
-- AkkaPort deliberately has no equivalent here: it is NOT NULL with a default of 4053, so
-- pre-existing rows already carry a usable value and there is nothing to backfill.
------------------------------------------------------------------------------
UPDATE dbo.ClusterNode
SET GrpcPort = 4056 -- matches Telemetry__GrpcListenPort on all six nodes in docker-compose.yml
WHERE GrpcPort IS NULL
AND CreatedBy = 'docker-dev-seed';
------------------------------------------------------------------------------ ------------------------------------------------------------------------------
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15) -- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
-- --
+39
View File
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state. Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
### Post-(re)load condition-node re-assert (#487)
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
never come.
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
Four properties are load-bearing:
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
machinery — or a future refactor — to mistake for a transition.
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
alarm's display name — and writes nothing at all on the common all-normal deploy.
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
these writes.
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
## Source integration ## Source integration
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md). `ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
+33
View File
@@ -128,6 +128,39 @@ transports. ScadaBridge itself has since closed that gap with this identical
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
later hardening pass. See the design doc's superseded note for the full history. later hardening pass. See the design doc's superseded note for the full history.
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
already existed. On an upgraded deployment every `ClusterNode` row therefore carries `NULL`,
central finds **no dial targets**, and flipping `TelemetryDial:Mode` to `Grpc` connects to
nothing. Fresh installs are unaffected. `AkkaPort` did not have this problem only because it is
`NOT NULL` with a default of 4053.
Nothing fails loudly, which is what makes this worth stating up front — the code degrades
gracefully, once per node, throttled:
```
ClusterNode <id> has no GrpcPort; it exposes no telemetry stream and is skipped in this dial refresh
(further skips of this node are silent)
```
Other symptoms: no `ESTABLISHED` sockets on the telemetry port (a `LISTEN` but nothing else in
`docker exec <node> cat /proc/net/tcp6`), and the `/alerts` / `/script-log` pill never turning
live in `Grpc` mode.
**Before flipping any node to `Grpc`**, set each driver node's `GrpcPort` to that node's own
`Telemetry:GrpcListenPort` — via the AdminUI node editor, or directly:
```sql
UPDATE dbo.ClusterNode SET GrpcPort = <that node's Telemetry:GrpcListenPort> WHERE NodeId = '<node>';
```
There is deliberately **no EF data migration** backfilling this. The correct value is that node's
own listener port, which is per-deployment configuration the database cannot derive; a migration
could only invent one, and a *wrong* dial target is worse than the `NULL` the dialer already skips
explicitly. The docker-dev seed does backfill (`GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'`
→ 4056) because there the port is fixed by `docker-compose.yml` and therefore actually knowable.
## Reconnect and reliability ## Reconnect and reliability
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
{ {
[Theory] [Theory]
[InlineData(192, 0x00000000u)] // Good [InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride [InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain [InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad [InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected [InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
{ {
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) }; var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
var snap = SampleMapper.ToAggregateSnapshot(a); var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value); Assert.Null(snap.Value);
} }
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts. // Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice). - `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind). - `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
- `ServerTimestampUtc`: `DateTime.UtcNow`. - `ServerTimestampUtc`: `DateTime.UtcNow`.
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too. - `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
**Step 4: run, expect PASS.** **Step 4: run, expect PASS.**
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` | | `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` (state word; §3.6) | | `CONDITION` | `String` (state word; §3.6) |
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node). **Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp``SourceTimestamp` (not a separate node).
### 3.4 `IReadable``/current` ### 3.4 `IReadable``/current`
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string `dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing. into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
and the decisions worth knowing before touching it:
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
to the string the catalog returned, so the text quoted into a poll query is one this driver read
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
match under a case-sensitive collation is refused rather than guessed, because picking one would
publish another column's data under the operator's node.
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
hunting a typo has to see what they wrote.
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
but stays in the *authored* table, so it still materializes as a node and reads
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
drop is logged at Warning with the tag, the field and the reason.
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
them would serve a confidently-empty address space and send the operator hunting typos that do not
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
wall-clock bound as the liveness check (R2-01 / STAB-14).
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
message pointing at the fix — expose the data through a view in the connected database.
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce - **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table` with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier), (`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
this keeps the factory shape unchanged. this keeps the factory shape unchanged.
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it - **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
in display/logging and flags it dev-only. the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
sees them). The key is matched case-insensitively, and the error text never echoes the value.
- **Never log the resolved connection string.** Log only provider + server host + database name. - **Never log the resolved connection string.** Log only provider + server host + database name.
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD / - Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
Kerberos) so no password transits config at all. Kerberos) so no password transits config at all.
+8 -1
View File
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
## Documented follow-ups (non-blocking) ## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.** - **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
(a projection also carries the definition's severity, the resolved message template, and the last-observed
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
nodes at all. The original deferral text follows for context.
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed: The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition `RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
@@ -41,9 +41,77 @@ public static class DraftValidator
ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors); ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors); ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
return errors; return errors;
} }
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// </summary>
/// <remarks>
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
/// Discarding a secret on read is not the same as refusing to store it.</para>
/// <para><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="DraftSnapshot"/> and is gated at its save instead (#499); see
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
/// never enters the artifact.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
/// device blob lands in exactly the same place.</para>
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
/// log and the audit trail; repeating the offending string there would leak the very credential the
/// rule is refusing to store.</para>
/// </remarks>
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
{
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
var sqlInstanceIds = draft.DriverInstances
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
.Select(d => d.DriverInstanceId)
.ToHashSet(StringComparer.Ordinal);
if (sqlInstanceIds.Count == 0) return;
foreach (var d in draft.DriverInstances)
{
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
"from the environment / secret store at Initialize; a literal connection string here would be " +
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
"Remove the key and set 'connectionStringRef'.",
d.DriverInstanceId));
}
foreach (var dev in draft.Devices)
{
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
errors.Add(new("SqlConnectionStringPersisted",
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
dev.DeviceId));
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver: /// <summary>WP7 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
@@ -0,0 +1,139 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// The one place that decides whether a persisted config blob carries a literal Sql
/// <c>connectionString</c> (Gitea #498 / #499).
/// </summary>
/// <remarks>
/// <para>
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
/// be written even though the runtime ignores it.
/// </para>
/// <para>
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
/// enforcement points:
/// <list type="bullet">
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
/// <c>DraftValidator</c>.</item>
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
/// single rule about a value that is not part of the artifact at all. More to the point, a
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
/// blocking a deploy would not stop the credential being stored. It is already in the
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
/// point where "refuse to store it" is literally true.</item>
/// </list>
/// </para>
/// <para>
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
/// would start refusing configs that are legitimate today for drivers that never made this
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
/// gains an indirect-credential contract of its own.
/// </para>
/// </remarks>
public static class SqlCredentialGuard
{
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
public const string ForbiddenKey = "connectionString";
/// <summary>
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
/// its top level.
/// </summary>
/// <remarks>
/// Matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds <c>ConnectionString</c> to a
/// <c>connectionString</c> property by default, so a case variant is the same key, not a
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
/// bind and is not the credential-shaped mistake this exists to catch.
/// </remarks>
/// <param name="configJson">The config blob to inspect.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
public static bool CarriesLiteralConnectionString(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return false;
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
}
catch (JsonException)
{
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
// throwing here would turn a formatting mistake into a credential-guard failure.
return false;
}
}
/// <summary>
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
/// override object carries a literal connection string, restricted to instances that are actually
/// Sql drivers.
/// </summary>
/// <remarks>
/// The override map is shaped <c>{ "&lt;DriverInstanceId&gt;": { …driver config keys… } }</c> and is
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
/// itself.
/// </remarks>
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
/// violations.</param>
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
public static IReadOnlyList<string> FindNodeOverrideViolations(
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
{
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
{
return [];
}
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
try
{
using var doc = JsonDocument.Parse(overridesJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
List<string>? offenders = null;
foreach (var entry in doc.RootElement.EnumerateObject())
{
if (!sqlIds.Contains(entry.Name)) continue;
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
(offenders ??= []).Add(entry.Name);
}
return (IReadOnlyList<string>?)offenders ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
/// case-insensitively.</summary>
private static bool HasTopLevelKey(JsonElement obj, string key)
{
foreach (var property in obj.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
public IReadOnlyCollection<AlarmConditionState> GetAllStates() public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray(); => _alarms.Values.Select(a => a.Condition).ToArray();
/// <summary>
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
/// severity, the rendered message template and the last-observed input quality, i.e. everything
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
/// the next transition.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
/// transition to report), so the transition stream cannot carry the current state — that is
/// exactly why the caller needs this. Routing these through the emission path instead would
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
/// intentionally one the emission machinery cannot consume.
/// </para>
/// <para>
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
/// read publishes its own transition through the normal path.
/// </para>
/// </remarks>
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
{
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
foreach (var (alarmId, state) in _alarms)
{
projections.Add(new ScriptedAlarmProjection(
AlarmId: alarmId,
Severity: state.Definition.Severity,
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
Condition: state.Condition,
WorstInputStatusCode: LastWorstStatus(alarmId)));
}
return projections;
}
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary> /// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
/// <param name="alarmId">The alarm identifier.</param> /// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the acknowledgment.</param> /// <param name="user">The user performing the acknowledgment.</param>
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged. // the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u); uint WorstInputStatusCode = 0u);
/// <summary>
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
/// for a future refactor to mistake for a transition.
/// </summary>
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
/// <param name="Severity">The definition's severity bucket.</param>
/// <param name="Message">The message template resolved against the current value cache.</param>
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
public sealed record ScriptedAlarmProjection(
string AlarmId,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
uint WorstInputStatusCode);
/// <summary> /// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag /// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge. /// engine's so Stream G can compose them behind one driver bridge.
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public static class AbCipStatusMapper public static class AbCipStatusMapper
{ {
public const uint Good = 0u; public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u; public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u; public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u; public const uint BadNotWritable = 0x803B0000u;
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary> /// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param> /// <param name="status">The CIP general-status byte value.</param>
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
public static class AbLegacyStatusMapper public static class AbLegacyStatusMapper
{ {
public const uint Good = 0u; public const uint Good = 0u;
public const uint GoodMoreData = 0x00A70000u; public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u; public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u; public const uint BadNotWritable = 0x803B0000u;
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary> /// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here /// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
/// <summary> /// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published /// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
{ {
rejectedTcs.TrySetResult(new DataValueSnapshot( rejectedTcs.TrySetResult(new DataValueSnapshot(
Value: null, Value: null,
StatusCode: 0x80000000u, // Bad StatusCode: StatusCodeMap.Bad,
SourceTimestampUtc: null, SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow)); ServerTimestampUtc: DateTime.UtcNow));
} }
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
{ {
tcs.TrySetResult(new DataValueSnapshot( tcs.TrySetResult(new DataValueSnapshot(
Value: null, Value: null,
StatusCode: 0x800B0000u, // BadTimeout StatusCode: StatusCodeMap.BadTimeout,
SourceTimestampUtc: null, SourceTimestampUtc: null,
ServerTimestampUtc: DateTime.UtcNow)); ServerTimestampUtc: DateTime.UtcNow));
} }
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
{ {
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good), // OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte. // 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
//
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
// originally written as though the DA byte could be shifted into the substatus position, which
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
// against the pinned SDK's Opc.Ua.StatusCodes.
public const uint Good = 0x00000000u; public const uint Good = 0x00000000u;
public const uint GoodLocalOverride = 0x00D80000u; public const uint GoodLocalOverride = 0x00960000u;
public const uint Uncertain = 0x40000000u; public const uint Uncertain = 0x40000000u;
public const uint UncertainLastUsableValue = 0x40A40000u; public const uint UncertainLastUsableValue = 0x40900000u;
public const uint UncertainSensorNotAccurate = 0x408D0000u; public const uint UncertainSensorNotAccurate = 0x40930000u;
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u; public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
public const uint UncertainSubNormal = 0x408F0000u; public const uint UncertainSubNormal = 0x40950000u;
public const uint Bad = 0x80000000u; public const uint Bad = 0x80000000u;
public const uint BadConfigurationError = 0x80890000u; public const uint BadConfigurationError = 0x80890000u;
public const uint BadNotConnected = 0x808A0000u; public const uint BadNotConnected = 0x808A0000u;
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
public const uint BadWaitingForInitialData = 0x80320000u; public const uint BadWaitingForInitialData = 0x80320000u;
public const uint BadInternalError = 0x80020000u; public const uint BadInternalError = 0x80020000u;
/// <summary>
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
/// </summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary> /// <summary>
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort, /// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s /// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
public static uint Map(byte q) => q switch public static uint Map(byte q) => q switch
{ {
// Good family (192+) // Good family (192+)
192 => 0x00000000u, // Good 192 => HistorianStatusCodes.Good,
216 => 0x00D80000u, // Good_LocalOverride 216 => HistorianStatusCodes.GoodLocalOverride,
// Uncertain family (64-191) // Uncertain family (64-191)
64 => 0x40000000u, // Uncertain 64 => HistorianStatusCodes.Uncertain,
68 => 0x40900000u, // Uncertain_LastUsableValue 68 => HistorianStatusCodes.UncertainLastUsableValue,
80 => 0x40930000u, // Uncertain_SensorNotAccurate 80 => HistorianStatusCodes.UncertainSensorNotAccurate,
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded 84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
88 => 0x40950000u, // Uncertain_SubNormal 88 => HistorianStatusCodes.UncertainSubNormal,
// Bad family (0-63) // Bad family (0-63)
0 => 0x80000000u, // Bad 0 => HistorianStatusCodes.Bad,
4 => 0x80890000u, // Bad_ConfigurationError 4 => HistorianStatusCodes.BadConfigurationError,
8 => 0x808A0000u, // Bad_NotConnected 8 => HistorianStatusCodes.BadNotConnected,
12 => 0x808B0000u, // Bad_DeviceFailure 12 => HistorianStatusCodes.BadDeviceFailure,
16 => 0x808C0000u, // Bad_SensorFailure 16 => HistorianStatusCodes.BadSensorFailure,
20 => 0x80050000u, // Bad_CommunicationError 20 => HistorianStatusCodes.BadCommunicationError,
24 => 0x808D0000u, // Bad_OutOfService 24 => HistorianStatusCodes.BadOutOfService,
32 => 0x80320000u, // Bad_WaitingForInitialData 32 => HistorianStatusCodes.BadWaitingForInitialData,
// Unknown — fall back to category bucket so callers still get something usable. // Unknown — fall back to category bucket so callers still get something usable.
_ when q >= 192 => 0x00000000u, _ when q >= 192 => HistorianStatusCodes.Good,
_ when q >= 64 => 0x40000000u, _ when q >= 64 => HistorianStatusCodes.Uncertain,
_ => 0x80000000u, _ => HistorianStatusCodes.Bad,
}; };
} }
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <summary>
/// The OPC UA status codes this driver publishes, as named constants.
/// </summary>
/// <remarks>
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
/// A literal written at a call site is invisible to that guard, which is exactly how
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
/// </remarks>
internal static class HistorianStatusCodes
{
// ---- Good family ----
/// <summary>The value is good; no qualification.</summary>
public const uint Good = 0x00000000u;
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
public const uint GoodLocalOverride = 0x00960000u;
// ---- Uncertain family ----
/// <summary>The value is uncertain; no specific reason.</summary>
public const uint Uncertain = 0x40000000u;
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
public const uint UncertainLastUsableValue = 0x40900000u;
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
public const uint UncertainSensorNotAccurate = 0x40930000u;
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
public const uint UncertainSubNormal = 0x40950000u;
// ---- Bad family ----
/// <summary>The value is bad; no specific reason.</summary>
public const uint Bad = 0x80000000u;
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
public const uint BadConfigurationError = 0x80890000u;
/// <summary>The source is not connected (OPC DA quality 8).</summary>
public const uint BadNotConnected = 0x808A0000u;
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
public const uint BadSensorFailure = 0x808C0000u;
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>The source is out of service (OPC DA quality 24).</summary>
public const uint BadOutOfService = 0x808D0000u;
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The historian returned no data for the requested tag/interval. Distinct from a transport
/// failure: the query succeeded and the answer was empty.
/// </summary>
public const uint BadNoData = 0x809B0000u;
}
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </summary> /// </summary>
internal static class SampleMapper internal static class SampleMapper
{ {
private const uint StatusGood = 0x00000000u; private const uint StatusGood = HistorianStatusCodes.Good;
private const uint StatusBadNoData = 0x800E0000u; private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary> /// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
private const byte GoodQualityFloor = 192; private const byte GoodQualityFloor = 192;
@@ -14,11 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// parameterized in SQL, so the few that must appear as text are emitted through /// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field /// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para> /// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Not yet implemented:</b> design §8.1 also specifies that an authored table/column is /// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier /// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// rejects the tag. No such gate exists yet — an identifier reaches <see cref="QuoteIdentifier"/> /// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// straight from the authored <c>TagConfig</c> blob, so <b>quoting is currently the only defence</b>, /// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// not a backstop behind an upstream filter. Scrutinise it accordingly.</para> /// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse /// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package; /// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract /// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
@@ -89,6 +91,20 @@ public interface ISqlDialect
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary> /// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; } string ListSchemasSql { get; }
/// <summary>
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
/// — the same dialect resolves differently for two different logins.</para>
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary> /// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
string ListTablesSql { get; } string ListTablesSql { get; }
@@ -0,0 +1,137 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
/// allow-list design §8.1 requires identifiers to come from.
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
/// identifier text in an emitted query is a string the database gave us.</para>
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
/// asks.</para>
/// </summary>
public sealed class SqlCatalog
{
private readonly IReadOnlyList<string> _schemas;
/// <summary>Canonical schema → canonical table names in it.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
/// <summary>Constructs a catalog snapshot.</summary>
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
/// <param name="schemas">Every schema the connection can see.</param>
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
internal SqlCatalog(
string defaultSchema,
IReadOnlyList<string> schemas,
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
{
DefaultSchema = defaultSchema;
_schemas = schemas;
_tablesBySchema = tablesBySchema;
_columnsByTable = columnsByTable;
}
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
public string DefaultSchema { get; }
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
public bool TryResolveSchema(string? authored, out string canonical)
{
if (string.IsNullOrWhiteSpace(authored))
{
canonical = DefaultSchema;
return true;
}
return TryMatch(_schemas, authored, out canonical);
}
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
/// <param name="authored">The authored table or view name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
{
canonical = string.Empty;
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
&& TryMatch(tables, authored, out canonical);
}
/// <summary>Resolves an authored column name within a canonical relation.</summary>
/// <param name="canonicalSchema">The resolved schema.</param>
/// <param name="canonicalTable">The resolved table or view.</param>
/// <param name="authored">The authored column name.</param>
/// <param name="canonical">The catalog's spelling, when resolved.</param>
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
public bool TryResolveColumn(
string canonicalSchema, string canonicalTable, string authored, out string canonical)
{
canonical = string.Empty;
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
&& TryMatch(columns, authored, out canonical);
}
/// <summary>The dictionary key for a canonical relation.</summary>
/// <param name="schema">The canonical schema.</param>
/// <param name="table">The canonical table.</param>
/// <returns>The composite key.</returns>
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
/// <summary>
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
/// </summary>
/// <remarks>
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
/// ordinally would reject configurations that have always been valid — a gate that breaks working
/// deployments is not defence in depth.</para>
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
/// such a config resolving to what the operator wrote, and refusing to choose between two
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
/// </remarks>
/// <param name="candidates">The catalog spellings to match against.</param>
/// <param name="authored">The authored name.</param>
/// <param name="canonical">The catalog's spelling, when matched.</param>
/// <returns><see langword="true"/> on an unambiguous match.</returns>
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
{
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
canonical = candidate;
return true;
}
canonical = string.Empty;
var matches = 0;
foreach (var candidate in candidates)
{
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
if (++matches > 1)
{
canonical = string.Empty;
return false;
}
canonical = candidate;
}
return matches == 1;
}
}
@@ -0,0 +1,241 @@
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
/// shape and the caller's tests can assert on the reason.
/// </summary>
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
/// <param name="Accepted">
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
/// <see cref="SqlGroupPlanner"/>.
/// </param>
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
public sealed record SqlCatalogGateResult(
IReadOnlyList<SqlTagDefinition> Accepted,
IReadOnlyList<SqlCatalogRejection> Rejected);
/// <summary>
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
/// </summary>
/// <remarks>
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
/// sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
/// than the whole story.</para>
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
/// must, or an operator has no way to find the typo.</para>
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
/// driver's table, so its RawPath resolves to nothing and it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
/// down" rule the tag-table build already follows.</para>
/// </remarks>
public static class SqlCatalogGate
{
/// <summary>
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
/// cannot see, so it cannot be allow-listed.
/// </summary>
private const int MaxNameParts = 2;
/// <summary>
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
/// with catalog-spelled identifiers and the rejected ones with reasons.
/// </summary>
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
/// <param name="catalog">The catalog snapshot to validate against.</param>
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
/// <returns>The accepted and rejected sets.</returns>
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
public static SqlCatalogGateResult Apply(
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
{
ArgumentNullException.ThrowIfNull(definitions);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(dialect);
var accepted = new List<SqlTagDefinition>();
var rejected = new List<SqlCatalogRejection>();
foreach (var definition in definitions)
{
ArgumentNullException.ThrowIfNull(definition);
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
accepted.Add(canonical!);
else
rejected.Add(rejection!);
}
return new SqlCatalogGateResult(accepted, rejected);
}
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
private static bool TryCanonicalize(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out SqlTagDefinition? canonical,
out SqlCatalogRejection? rejection)
{
canonical = null;
rejection = null;
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
return false;
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
// model does not use is null and simply skipped — the planner enforces which are required.
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var (field, authored) in new (string Field, string? Authored)[]
{
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
})
{
if (string.IsNullOrWhiteSpace(authored))
{
resolved[field] = authored;
continue;
}
if (!IsRenderableIdentifier(authored, dialect))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
"or Unicode format characters); the value is withheld from this message because it " +
"cannot be safely rendered");
return false;
}
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
{
rejection = new SqlCatalogRejection(definition.Name, field,
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
"column under a case-sensitive collation)");
return false;
}
resolved[field] = column;
}
canonical = definition with
{
Table = SqlCatalog.QualifiedKey(schema, table),
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
};
return true;
}
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
private static bool TryResolveRelation(
SqlTagDefinition definition,
SqlCatalog catalog,
ISqlDialect dialect,
out string schema,
out string table,
out SqlCatalogRejection? rejection)
{
schema = string.Empty;
table = string.Empty;
rejection = null;
const string Field = nameof(SqlTagDefinition.Table);
if (string.IsNullOrWhiteSpace(definition.Table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
"the tag has no 'table'; author the table or view to read");
return false;
}
var parts = definition.Table.Split('.');
if (parts.Length > MaxNameParts)
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
"validated against this connection's catalog, so a cross-database or linked-server name " +
"cannot be allow-listed; expose the data through a view in this database instead");
return false;
}
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
var authoredTable = parts[^1];
foreach (var part in parts)
{
if (IsRenderableIdentifier(part, dialect)) continue;
rejection = new SqlCatalogRejection(definition.Name, Field,
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
"containing control or Unicode format characters); the value is withheld from this message " +
"because it cannot be safely rendered");
return false;
}
if (!catalog.TryResolveSchema(authoredSchema, out schema))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
"case-sensitive collation)");
return false;
}
if (!catalog.TryResolveTable(schema, authoredTable, out table))
{
rejection = new SqlCatalogRejection(definition.Name, Field,
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
(authoredSchema is null
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
: string.Empty));
return false;
}
return true;
}
/// <summary>
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
/// embed in SQL and to render into a log line or an AdminUI label.
/// </summary>
/// <remarks>
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
/// discarded: only whether it threw is interesting here.
/// </remarks>
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
{
try
{
dialect.QuoteIdentifier(identifier);
return true;
}
catch (ArgumentException)
{
return false;
}
}
}
@@ -0,0 +1,189 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
/// dialect's catalog SQL (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
/// one <see cref="ISqlDialect.ListColumnsSql"/> per distinct authored relation. A driver polling three
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
/// confidently-empty address space.</para>
/// </remarks>
internal static class SqlCatalogLoader
{
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
private const string SchemaColumn = "TABLE_SCHEMA";
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
private const string TableNameColumn = "TABLE_NAME";
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
/// <param name="dialect">Supplies the catalog SQL.</param>
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
/// <param name="commandTimeout">Per-command server-side backstop.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The loaded catalog.</returns>
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
internal static async Task<SqlCatalog> LoadAsync(
DbConnection connection,
ISqlDialect dialect,
IReadOnlyCollection<string> authoredTables,
TimeSpan commandTimeout,
CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
connection, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
// that basis would report an authoring fault for what is actually a grant problem, and would send the
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
if (schemas.Count == 0)
throw new InvalidOperationException(
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
"the catalog views, which is a grant problem rather than a tag-authoring one");
var defaultSchema = await ReadDefaultSchemaAsync(
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
// one table and validating against another would be a silent wrong-column defect.
foreach (var authored in authoredTables)
{
if (string.IsNullOrWhiteSpace(authored)) continue;
var parts = authored.Split('.');
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
var authoredTable = parts[^1];
string schema;
if (parts.Length == 2)
{
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
}
else
{
schema = defaultSchema;
}
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
connection, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, "@schema", schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
var key = SqlCatalog.QualifiedKey(schema, table);
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
connection, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
}
/// <summary>
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
/// query answers null/blank.
/// </summary>
/// <remarks>
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
/// visible schema keeps the common one-schema database working; with several visible schemas there is
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
/// </remarks>
private static async Task<string> ReadDefaultSchemaAsync(
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var schema = value is null or DBNull ? null : value.ToString();
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
}
}
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
string sql,
string columnName,
Action<DbCommand> bind,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var results = new List<string>();
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
var reader = await command
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
var ordinal = -1;
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal)) continue;
var value = reader.GetValue(ordinal)?.ToString();
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
}
}
}
return results;
}
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
private static void Bind(DbCommand command, string name, string value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.DbType = DbType.String;
parameter.Value = value;
command.Parameters.Add(parameter);
}
}
@@ -74,7 +74,23 @@ public sealed class SqlDriver
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath = private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal); new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.</summary> /// <summary>
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
/// reach a query.
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
/// node: §8.1's specified outcome is that it publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, and a status code can only be published by a node
/// that is there. Dropping rejected tags from the authored table too would delete the node instead,
/// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse
/// failure for the operator trying to find their typo.</para>
/// <para>Swapped atomically, for the same reason the authored table is.</para>
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver; private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
/// <summary> /// <summary>
@@ -182,8 +198,18 @@ public sealed class SqlDriver
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary> /// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath); private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
/// <summary>The resolver's lookup: RawPath → authored definition, or null on a miss.</summary> /// <summary>
private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath); /// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
/// </summary>
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
/// <summary>
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
/// rejected must miss here so the reader publishes
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
/// </summary>
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
// ---- IDriver lifecycle ---- // ---- IDriver lifecycle ----
@@ -196,11 +222,16 @@ public sealed class SqlDriver
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> — /// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its /// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para> /// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
/// (<see cref="ApplyCatalogGateAsync"/>), which is the second and last piece of I/O. It runs after
/// liveness because it needs a working connection, and before the driver reports Healthy because the
/// tag table it publishes must be the validated one — a poll must never see an unvalidated
/// identifier.</para>
/// </summary> /// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param> /// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param> /// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">The database could not be reached.</exception> /// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{ {
WriteHealth(new DriverHealth(DriverState.Initializing, null, null)); WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
@@ -234,6 +265,36 @@ public sealed class SqlDriver
throw new InvalidOperationException(message, ex); throw new InvalidOperationException(message, ex);
} }
// Separate try from liveness so the operator surface names the stage that actually failed: "could
// not reach the database" and "reached it but could not read its catalog" send an operator to
// different places, and the second is usually a GRANT rather than an outage.
try
{
await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
throw;
}
catch (Exception ex)
{
// Same discipline as above: exception TYPE only on the operator surface, full exception to the
// log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of
// evidence — so this faults the driver (and retries) rather than rejecting every tag and serving
// a confidently-empty address space.
var message =
$"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " +
$"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " +
"can read the catalog views.";
_logger.LogError(ex,
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running); TransitionHostTo(HostState.Running);
_logger.LogInformation( _logger.LogInformation(
@@ -594,6 +655,94 @@ public sealed class SqlDriver
} }
Volatile.Write(ref _tagsByRawPath, table); Volatile.Write(ref _tagsByRawPath, table);
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
// previous generation's validated table in place) means a Reinitialize whose gate then fails cannot
// keep polling the OLD identifiers against a database whose schema may be exactly what changed.
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
}
// ---- catalog gate (design §8.1) ----
/// <summary>
/// Validates every authored identifier against the live catalog and republishes the tag table with
/// catalog spellings, dropping the tags that do not resolve (design §8.1).
/// </summary>
/// <remarks>
/// <para><b>Why this must run before the driver reports Healthy.</b> The table this swaps in is what
/// every poll resolves against. Running it later — or in the background — would leave a window in
/// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate
/// exists to close.</para>
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
/// typo must not stop the other tags on that database, the same rule
/// <see cref="BuildTagTable"/> follows for a malformed blob. Each drop is logged at Warning with the
/// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is
/// unsupportable.</para>
/// <para><b>No tags ⇒ no I/O.</b> A driver with nothing authored has nothing to validate, and issuing
/// catalog queries to prove that would be a round-trip that can only fail.</para>
/// <para>Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work
/// runs on the thread pool so a provider implementing the async path synchronously blocks a pool
/// thread rather than wedging Initialize forever with no retry.</para>
/// </remarks>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken)
{
var authored = Tags;
if (authored.Count == 0) return;
var tables = authored.Values
.Select(definition => definition.Table)
.Where(table => !string.IsNullOrWhiteSpace(table))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = await RunBoundedAsync(
token => LoadCatalogAsync(tables, token),
_options.OperationTimeout,
"the catalog queries did not return",
cancellationToken).ConfigureAwait(false);
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
foreach (var rejection in result.Rejected)
{
_logger.LogWarning(
"Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish "
+ "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}",
rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId);
}
var validated = new Dictionary<string, SqlTagDefinition>(result.Accepted.Count, StringComparer.Ordinal);
foreach (var definition in result.Accepted) validated[definition.Name] = definition;
// Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps
// its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath).
Volatile.Write(ref _polledByRawPath, validated);
_logger.LogInformation(
"Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) "
+ "across {Tables} table(s) on {Endpoint}.",
_driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint);
}
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
private async Task<SqlCatalog> LoadCatalogAsync(
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
{
var connection = _factory.CreateConnection()
?? throw new InvalidOperationException(
$"the {_dialect.Provider} provider factory returned no connection.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = _connectionString;
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return await SqlCatalogLoader
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
.ConfigureAwait(false);
}
} }
// ---- liveness ---- // ---- liveness ----
@@ -611,18 +760,50 @@ public sealed class SqlDriver
/// </summary> /// </summary>
/// <param name="cancellationToken">The caller's token.</param> /// <param name="cancellationToken">The caller's token.</param>
/// <returns>A task that completes when the database has answered.</returns> /// <returns>A task that completes when the database has answered.</returns>
private async Task VerifyLivenessAsync(CancellationToken cancellationToken) private Task VerifyLivenessAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(
async token => { await PingAsync(token).ConfigureAwait(false); return true; },
_options.CommandTimeout,
"the liveness statement did not return",
cancellationToken);
/// <summary>
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
/// </summary>
/// <remarks>
/// <para>The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists
/// because a token is not a deadline: some ADO.NET providers implement the async path synchronously,
/// and a wedged socket can hang <em>inside</em> the provider's own cancellation handshake. Without an
/// independent wall clock, <c>DriverInstanceActor</c>'s init task would never complete and the driver
/// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped.</para>
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
/// </remarks>
/// <typeparam name="T">The work's result type.</typeparam>
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
/// <param name="budget">The wall-clock allowance.</param>
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
/// <param name="cancellationToken">The caller's token.</param>
/// <returns>The work's result.</returns>
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
private static async Task<T> RunBoundedAsync<T>(
Func<CancellationToken, Task<T>> work,
TimeSpan budget,
string what,
CancellationToken cancellationToken)
{ {
var budget = _options.CommandTimeout;
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task work; Task<T> running;
try try
{ {
deadline.CancelAfter(budget); deadline.CancelAfter(budget);
work = Task.Run( running = Task.Run(
async () => async () =>
{ {
try { await PingAsync(deadline.Token).ConfigureAwait(false); } try { return await work(deadline.Token).ConfigureAwait(false); }
finally { deadline.Dispose(); } finally { deadline.Dispose(); }
}, },
CancellationToken.None); CancellationToken.None);
@@ -636,20 +817,18 @@ public sealed class SqlDriver
try try
{ {
await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false); return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
} }
catch (TimeoutException) catch (TimeoutException)
{ {
Detach(work); Detach(running);
throw new TimeoutException( throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
// Our deadline fired and the provider DID honour the linked token. // Our deadline fired and the provider DID honour the linked token.
Detach(work); Detach(running);
throw new TimeoutException( throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
} }
} }
@@ -752,6 +931,7 @@ public sealed class SqlDriver
{ {
await _poll.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false);
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal)); Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
} }
} }
@@ -44,6 +44,12 @@ public sealed class SqlServerDialect : ISqlDialect
public string ListSchemasSql => public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
/// <summary>
/// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
/// </summary>
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
/// <inheritdoc/> /// <inheritdoc/>
public string ListTablesSql => public string ListTablesSql =>
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " + "SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
@@ -70,9 +76,11 @@ public sealed class SqlServerDialect : ISqlDialect
/// control-character rule.</para> /// control-character rule.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted /// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para> /// input and this exception's text reaches the driver log.</para>
/// <para><b>This is currently the only defence, not a backstop.</b> Design §8.1 specifies that an /// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// identifier is validated against the live catalog before reaching here; that gate is not implemented /// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// yet, so an authored <c>TagConfig</c> table/column arrives unfiltered.</para> /// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
/// </remarks> /// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param> /// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The bracket-quoted identifier.</returns> /// <returns>The bracket-quoted identifier.</returns>
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
public const uint BadDeviceFailure = 0x808B0000u; public const uint BadDeviceFailure = 0x808B0000u;
public const uint BadCommunicationError = 0x80050000u; public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u; public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u; public const uint BadTypeMismatch = 0x80740000u;
public const uint BadOutOfService = 0x80BE0000u; public const uint BadOutOfService = 0x808D0000u;
public const uint BadInvalidState = 0x80350000u; public const uint BadInvalidState = 0x80AF0000u;
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ---- // ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
@@ -81,7 +81,7 @@ else
<tr> <tr>
<td><span class="mono small">@c.Subject</span></td> <td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td> <td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td> <td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
<td>@c.NotBefore.ToString("u")</td> <td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td> <td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) @if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
@@ -58,7 +58,7 @@ else
} }
else else
{ {
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div> <div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div> <div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div> <div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
@if (_lastDeployment.SealedAtUtc is not null) @if (_lastDeployment.SealedAtUtc is not null)
@@ -9,6 +9,8 @@
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory @inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav @inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState @inject AuthenticationStateProvider AuthState
@@ -34,6 +36,10 @@ else
{ {
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit"> <EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
@* Without this, a validation failure suppresses OnValidSubmit with NO feedback whatsoever —
the Save button simply does nothing and the operator has no way to tell why. That is how the
NodeId-regex defect above stayed invisible. *@
<ValidationSummary class="alert alert-danger py-2 px-3" />
<section class="panel rise" style="animation-delay:.02s"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div> <div class="panel-head">Identity</div>
<div style="padding:1rem"> <div style="padding:1rem">
@@ -178,6 +184,33 @@ else
} }
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
// #499 — the third Sql-credential surface. DriverConfigOverridesJson is merged onto the
// cluster-level DriverConfig, so a literal connectionString pasted here leaks exactly as one
// pasted into the driver itself. The deploy gate cannot see it (DraftSnapshot carries no
// ClusterNode rows) and would be the wrong place anyway: node overrides never enter the
// artifact, so by the time a deploy ran the credential would already be stored. Refuse the
// save instead. The message names the driver instance but NEVER the value.
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
{
var sqlDriverIds = await db.DriverInstances
.Where(d => d.ClusterId == ClusterId && d.DriverType == DriverTypeNames.Sql)
.Select(d => d.DriverInstanceId)
.ToListAsync();
var offenders = SqlCredentialGuard.FindNodeOverrideViolations(
_form.DriverConfigOverridesJson, sqlDriverIds);
if (offenders.Count > 0)
{
_error =
$"Driver config overrides carry a '{SqlCredentialGuard.ForbiddenKey}' for Sql driver " +
$"instance(s) {string.Join(", ", offenders)}. A Sql driver must name its credentials " +
"indirectly via 'connectionStringRef', which resolves from the environment / secret " +
"store at Initialize; a literal connection string here would be stored in the config " +
"database, and the runtime ignores it anyway. Remove the key.";
return;
}
}
if (IsNew) if (IsNew)
{ {
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId)) if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
@@ -194,7 +227,7 @@ else
OpcUaPort = _form.OpcUaPort, OpcUaPort = _form.OpcUaPort,
DashboardPort = _form.DashboardPort, DashboardPort = _form.DashboardPort,
ApplicationUri = _form.ApplicationUri, ApplicationUri = _form.ApplicationUri,
ServiceLevelBase = _form.ServiceLevelBase, ServiceLevelBase = (byte)_form.ServiceLevelBase,
Enabled = _form.Enabled, Enabled = _form.Enabled,
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson, DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -210,7 +243,7 @@ else
entity.OpcUaPort = _form.OpcUaPort; entity.OpcUaPort = _form.OpcUaPort;
entity.DashboardPort = _form.DashboardPort; entity.DashboardPort = _form.DashboardPort;
entity.ApplicationUri = _form.ApplicationUri; entity.ApplicationUri = _form.ApplicationUri;
entity.ServiceLevelBase = _form.ServiceLevelBase; entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
entity.Enabled = _form.Enabled; entity.Enabled = _form.Enabled;
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson; entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
} }
@@ -254,14 +287,26 @@ else
private sealed class FormModel private sealed class FormModel
{ {
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] // The ':' and '.' are REQUIRED, not permissive. Every NodeId in this system is "host:port" —
// ClusterRoleInfo and ConfigPublishCoordinator derive it as member.Address.Host:Port, the seed
// writes "central-1:4053", and NodeDeploymentState.NodeId is FK-bound to it. The previous
// pattern forbade the colon, so EVERY existing node failed validation and OnValidSubmit never
// ran: the Save button did nothing at all, silently. Hostnames may also be dotted
// (line3-opc-a.plant.local:4053).
[Required, RegularExpression("^[A-Za-z0-9_.:-]+$")]
public string NodeId { get; set; } = ""; public string NodeId { get; set; } = "";
[Required] public string Host { get; set; } = ""; [Required] public string Host { get; set; } = "";
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840; [Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081; [Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")] [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
public string ApplicationUri { get; set; } = ""; public string ApplicationUri { get; set; } = "";
[Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200; // int, NOT byte. Blazor's InputNumber<T> rejects System.Byte outright — its static
// constructor throws "The type 'System.Byte' is not a supported numeric type", which escapes
// BuildRenderTree unhandled and takes the WHOLE page to an HTTP 500. Binding this field to
// the entity's own byte type meant /clusters/{id}/nodes/{nodeId} and .../nodes/new never
// rendered at all. The Range attribute still holds it to a byte's domain, and SubmitAsync
// casts on the way to the entity, so the stored type is unchanged.
[Range(0, 255)] public int ServiceLevelBase { get; set; } = 200;
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public string? DriverConfigOverridesJson { get; set; } public string? DriverConfigOverridesJson { get; set; }
} }
@@ -55,7 +55,7 @@
{ {
<tr> <tr>
<td><code>@Short(d.DeploymentId)</code></td> <td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td> <td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td> <td>@d.Status</td>
<td>@d.CreatedBy</td> <td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td> <td>@d.CreatedAtUtc.ToString("u")</td>
@@ -113,7 +113,7 @@
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted; _lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch _lastMessage = result.Outcome switch
{ {
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).", StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.", StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.", StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.", _ => result.Message ?? "Deployment rejected.",
@@ -38,7 +38,7 @@ else
<span class="mono">@s.ScriptId</span> <span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span> &middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span> &middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span> <span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
</summary> </summary>
<div style="padding:0 1rem 1rem"> <div style="padding:0 1rem 1rem">
<div class="d-flex mb-2"> <div class="d-flex mb-2">
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Rendering helpers for operator-facing text that comes out of the database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
/// </para>
/// <para>
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
/// <c>h1…</c>, so the display never implies more text than exists.
/// </para>
/// </remarks>
public static class DisplayText
{
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
/// Admin UI already uses for "nothing to show" cells.</summary>
public const string Empty = "—";
/// <summary>
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
/// ellipsis when (and only when) characters were dropped.
/// </summary>
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
/// <paramref name="maxLength"/> — none of which throw.</param>
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
/// so a caller cannot turn a display bug into a crash.</param>
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
/// otherwise the truncated prefix plus an ellipsis.</returns>
public static string Abbreviate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return Empty;
if (maxLength < 1) maxLength = 1;
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
}
}
@@ -170,6 +170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests /// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
/// inject a tiny value so the loop runs without real-time waits.</summary> /// inject a tiny value so the loop runs without real-time waits.</summary>
private readonly TimeSpan _rediscoverInterval; private readonly TimeSpan _rediscoverInterval;
private readonly TimeSpan _healthPollInterval;
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising /// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary> /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
@@ -239,6 +240,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param> /// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability /// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param> /// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns> /// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
public static Props Props( public static Props Props(
IDriver driver, IDriver driver,
@@ -249,7 +253,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null, TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null) => IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor( Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver, driver,
reconnectInterval ?? DefaultReconnectInterval, reconnectInterval ?? DefaultReconnectInterval,
@@ -259,7 +264,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
rediscoverInterval, rediscoverInterval,
rediscoverMaxAttempts, rediscoverMaxAttempts,
rediscoverDiscoverTimeout, rediscoverDiscoverTimeout,
invoker)); invoker,
healthPollInterval));
/// <summary> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -293,6 +299,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param> /// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls; /// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param> /// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor( public DriverInstanceActor(
IDriver driver, IDriver driver,
TimeSpan reconnectInterval, TimeSpan reconnectInterval,
@@ -302,7 +310,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null, TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null) IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null)
{ {
_driver = driver; _driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
@@ -313,6 +322,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
_rediscoverMaxAttempts = rediscoverMaxAttempts; _rediscoverMaxAttempts = rediscoverMaxAttempts;
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"), new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair<string, object?>("driver_type", driver.DriverType)); new KeyValuePair<string, object?>("driver_type", driver.DriverType));
@@ -335,7 +345,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// actor starts, before any state transition fires. Also start the periodic heartbeat so // actor starts, before any state transition fires. Also start the periodic heartbeat so
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients. // long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
PublishHealthSnapshot(); PublishHealthSnapshot();
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval); Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
} }
private void Stubbed() private void Stubbed()
@@ -489,7 +499,58 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter. // to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
Receive<SubscriptionFailed>(msg => Receive<SubscriptionFailed>(msg =>
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
Receive<HealthPollTick>(_ => PublishHealthSnapshot()); Receive<HealthPollTick>(_ =>
{
PublishHealthSnapshot();
ReconcileSubscription();
});
}
/// <summary>
/// Periodic desired-vs-actual subscription reconcile (#488), riding the existing <c>health-poll</c>
/// tick. While Connected, a driver that has a desired subscription set but no live handle is
/// re-subscribed.
/// </summary>
/// <remarks>
/// <para>
/// The desired set is otherwise only (re)applied on a <b>Connected transition</b>
/// (<see cref="ResubscribeDesired"/>) or on a deploy's <see cref="SetDesiredSubscriptions"/>.
/// Nothing re-asserted it for a subscription that was lost while the driver <i>stayed</i>
/// Connected — a failed subscribe whose retry never came, or a handle dropped down an error
/// path. That is the hole this closes.
/// </para>
/// <para>
/// <b>This is a state-machine consistency check, not a probe.</b> It can only see that this
/// actor holds no handle; it cannot detect a handle that is present but dead server-side,
/// because <c>ISubscriptionHandle</c> is opaque (a <c>DiagnosticId</c> string and nothing
/// else). Detecting that needs a liveness member on <c>ISubscribable</c> implemented across
/// all eight drivers, each with different subscription mechanics — deliberately deferred
/// until a real occurrence shows the handle outliving the subscription.
/// </para>
/// <para>
/// Gated on <see cref="ISubscribable"/>: a driver that cannot subscribe at all may still have
/// been handed a desired set, and re-telling <c>Subscribe</c> to it every tick would fail
/// every tick, forever.
/// </para>
/// <para>
/// A redundant <c>Subscribe</c> is possible but harmless: a tick sitting in the mailbox ahead
/// of an already-queued <c>Subscribe</c> observes the same "no handle" state and tells a
/// second one. <see cref="HandleSubscribeAsync"/> is a <c>ReceiveAsync</c> (so no tick can be
/// processed mid-subscribe) and drops any prior subscription before establishing the new one,
/// so the duplicate costs one extra round-trip and converges. Suppressing it would need a
/// pending-subscribe flag threaded through every self-tell site — more state than the race
/// is worth.
/// </para>
/// </remarks>
private void ReconcileSubscription()
{
if (_driver is not ISubscribable) return;
if (_desiredRefs.Count == 0 || _subscriptionHandle is not null) return;
_log.Info(
"DriverInstance {Id}: connected with {Count} desired subscription ref(s) but no live subscription handle — re-subscribing (periodic reconcile)",
_driverInstanceId, _desiredRefs.Count);
Self.Tell(new Subscribe(_desiredRefs, _desiredInterval));
} }
private void Reconnecting() private void Reconnecting()
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed. // so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self)); _mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count); _log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
ReassertConditionNodes();
} }
/// <summary>
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
/// its condition node, right after a (re)load.
///
/// <para>
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
/// re-derives Active from the predicate, so a still-active alarm ends the reload
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
/// client until its next real transition, which may never come. Same shape as the VirtualTag
/// redeploy-reset the <c>ReassertValue</c> fix closed.
/// </para>
///
/// <para>
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
/// </para>
///
/// <para>
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
/// already queued ahead of these writes: the node exists by the time they are handled.
/// </para>
///
/// <para>
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
/// matches what the materialise just built, so writing it would be a pure no-op on state —
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
/// display name; a projection carries the resolved template). Skipping them keeps a
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
/// at zero on the overwhelmingly common all-normal deploy.
/// </para>
///
/// <para>
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
/// like every other node write here: the Secondary keeps its address space warm for failover.
/// </para>
/// </summary>
private void ReassertConditionNodes()
{
var reasserted = 0;
foreach (var projection in _engine.GetProjections())
{
if (IsInNoEventPosition(projection.Condition))
{
continue;
}
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: projection.AlarmId,
State: ToSnapshot(projection),
// The instant the state we are re-asserting actually came about — NOT the deploy time.
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
// active, not when the address space happened to be rebuilt.
TimestampUtc: projection.Condition.LastTransitionUtc,
Realm: AddressSpaceRealm.Uns));
reasserted++;
}
if (reasserted > 0)
{
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
}
}
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
/// <param name="condition">The engine's current condition state.</param>
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
private static bool IsInNoEventPosition(AlarmConditionState condition)
=> condition.Enabled == AlarmEnabledState.Enabled
&& condition.Active == AlarmActiveState.Inactive
&& condition.Acked == AlarmAckedState.Acknowledged
&& condition.Confirmed == AlarmConfirmedState.Confirmed
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg) private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
{ {
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source // Feed the live value into the upstream the engine subscribes from. #478 — carry the source
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine /// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind /// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary> /// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new( private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
Active: e.Condition.Active == AlarmActiveState.Active, => ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed, /// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled, /// to restore a condition node after a rebuild, never an emission. Shares
Shelving: MapShelving(e.Condition.Shelving.Kind), /// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
Severity: (ushort)SeverityToInt(e.Severity), /// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
Message: e.Message, /// <param name="p">The engine projection to map.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
/// paths.</summary>
/// <param name="condition">The Part 9 condition state.</param>
/// <param name="severity">The engine's coarse severity bucket.</param>
/// <param name="message">The resolved condition message.</param>
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
Active: condition.Active == AlarmActiveState.Active,
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(severity),
Message: message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation // #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects // time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good. // Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(e.WorstInputStatusCode)); Quality: QualityFromStatus(worstInputStatusCode));
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/> /// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary> /// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
/// <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
private const string Code = "SqlConnectionStringPersisted";
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
private const string LeakedConfig =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private static DriverInstance SqlDriver(string config) => new()
{
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
DriverType = "Sql", DriverConfig = config,
};
private static Device Device(string driverInstanceId, string config) => new()
{
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
};
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = [driver],
Devices = device is null ? [] : [device],
};
[Fact]
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
/// problem without repeating the credential it is refusing to store.
/// </summary>
[Fact]
public void Error_message_does_not_echo_the_credential()
{
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
error.Message.ShouldNotContain("hunter2");
error.Message.ShouldNotContain("Server=sql,1433");
error.Message.ShouldContain("connectionStringRef");
}
/// <summary>
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
/// </summary>
[Theory]
[InlineData("ConnectionString")]
[InlineData("CONNECTIONSTRING")]
[InlineData("connectionstring")]
public void Key_match_is_case_insensitive(string key)
{
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
DraftValidator.Validate(Draft(SqlDriver(config)))
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
/// there is the identical leak and must fail the same way.
/// </summary>
[Fact]
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
{
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
}
[Fact]
public void A_properly_authored_connectionStringRef_passes()
{
var draft = Draft(
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
Device("di-sql", """{"pollIntervalMs":1000}"""));
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
/// be a regression, not defence in depth.
/// </summary>
[Fact]
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
{
var modbus = new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = LeakedConfig,
};
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
/// </summary>
[Fact]
public void A_device_under_a_non_Sql_driver_is_not_flagged()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [Device("di-mb", LeakedConfig)],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
/// rule's job, and a parse failure here would take down every other check in the same pass.
/// </summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("\"connectionString\"")]
[InlineData("{\"provider\":\"SqlServer\"")]
public void Malformed_config_neither_throws_nor_flags(string config)
{
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
.ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
/// positive on, say, a tag blob that happens to describe a connection string.
/// </summary>
[Fact]
public void A_nested_connectionString_is_not_flagged()
{
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
}
}
@@ -0,0 +1,132 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// <see cref="SqlCredentialGuard"/> — the shared "a Sql driver never persists a literal connection
/// string" check behind both the #498 deploy gate and the #499 node-override save gate.
/// </summary>
/// <remarks>
/// The node-override half is the one #499 is about: <c>ClusterNode.DriverConfigOverridesJson</c> is a
/// map keyed by <c>DriverInstanceId</c> that is merged onto the cluster-level <c>DriverConfig</c>, and
/// it is invisible to <c>DraftSnapshot</c> — so nothing gated it at all before.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SqlCredentialGuardTests
{
/// <summary>The literal an operator would paste into a raw-JSON textarea.</summary>
private const string Leaked =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}""";
// ---- CarriesLiteralConnectionString --------------------------------------------------------
/// <summary>The key is caught at the top level of a config blob.</summary>
[Fact]
public void A_literal_connectionString_is_detected()
=> SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue();
/// <summary>The supported indirect form is not a violation — otherwise the rule would block the fix
/// it tells operators to apply.</summary>
[Fact]
public void The_indirect_connectionStringRef_form_is_clean()
=> SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse();
/// <summary>System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by
/// default, so a case variant is the same key — not a bypass.</summary>
[Theory]
[InlineData("""{"ConnectionString":"Server=x;Password=p"}""")]
[InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")]
[InlineData("""{"connectionstring":"Server=x;Password=p"}""")]
public void Case_variants_are_the_same_key(string json)
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue();
/// <summary>Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is
/// another rule's job, and throwing here would turn a formatting mistake into a credential failure.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{ not json")]
[InlineData("[1,2,3]")]
[InlineData("\"a string\"")]
public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json)
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse();
/// <summary>Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is
/// not the credential-shaped mistake this rule exists to catch.</summary>
[Fact]
public void A_nested_occurrence_is_not_flagged()
=> SqlCredentialGuard
.CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""")
.ShouldBeFalse();
// ---- FindNodeOverrideViolations (#499) -----------------------------------------------------
/// <summary>The #499 leak: a credential pasted into a node's per-driver override map.</summary>
[Fact]
public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation()
{
var overrides = $$"""{"di-sql": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"])
.ShouldBe(["di-sql"]);
}
/// <summary>Every offending instance is reported, not just the first — an operator fixing one at a
/// time would otherwise need as many save attempts as there are leaks.</summary>
[Fact]
public void Every_offending_instance_is_reported()
{
var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"])
.ShouldBe(["di-a", "di-b"]);
}
/// <summary>Keys outside the Sql set are ignored. A non-Sql driver never made the
/// indirect-credential guarantee, so flagging it would break a config that is legitimate today —
/// a regression, not defence in depth.</summary>
[Fact]
public void An_override_for_a_non_Sql_driver_is_ignored()
{
var overrides = $$"""{"di-modbus": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
}
/// <summary>The realistic override — a node-specific endpoint — must keep saving.</summary>
[Fact]
public void A_normal_override_is_clean()
{
var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
}
/// <summary>Nothing to check when the cluster has no Sql drivers, or the node has no overrides.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{ not json")]
[InlineData("[1,2,3]")]
public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides)
=> SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
/// <summary>An empty Sql-driver set short-circuits — there is no instance the key could belong to.</summary>
[Fact]
public void No_Sql_drivers_means_no_violations()
=> SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", [])
.ShouldBeEmpty();
/// <summary>A non-object override entry cannot carry config keys, and must not throw.</summary>
[Fact]
public void A_non_object_override_entry_is_skipped()
=> SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"])
.ShouldBeEmpty();
}
@@ -0,0 +1,160 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards every driver's hard-coded OPC UA status-code constant against
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
/// <em>name</em> disagrees with its <em>value</em> fails here.
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
/// Gitea #497 found in six places across four drivers.</para>
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
/// </summary>
public sealed class StatusCodeParityTests
{
/// <summary>
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
/// that does not start with one of them is not a status constant.
/// </summary>
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
/// <summary>
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
/// </summary>
private static readonly string[] NamePrefixesToStrip = ["Status"];
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
typeof(Opc.Ua.StatusCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
/// <summary>
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
/// </summary>
/// <remarks>
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
/// modifiers say nothing about whether a value reaches an OPC UA client.
/// </remarks>
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
{
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type?[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
foreach (var type in types)
{
if (type is null) continue;
var fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
foreach (var field in fields)
{
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
var sdkName = StripKnownPrefix(field.Name);
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
data.Add(
asm.GetName().Name!,
type.FullName ?? type.Name,
field.Name,
sdkName,
(uint)field.GetRawConstantValue()!);
}
}
}
return data;
}
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
private static string StripKnownPrefix(string fieldName)
{
foreach (var prefix in NamePrefixesToStrip)
{
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
return fieldName[prefix.Length..];
}
return fieldName;
}
/// <summary>
/// A driver's status constant must carry the value the SDK gives the code it is named after.
/// </summary>
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
/// <param name="type">The declaring type's full name (failure-message context).</param>
/// <param name="field">The constant's name as declared.</param>
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
/// <param name="declared">The value the driver declares.</param>
[Theory]
[MemberData(nameof(DeclaredStatusConstants))]
public void Driver_status_constant_matches_the_pinned_SDK(
string assembly, string type, string field, string sdkName, uint declared)
{
SdkStatusCodes.ShouldContainKey(sdkName,
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
"not start with Good/Uncertain/Bad.");
var expected = SdkStatusCodes[sdkName];
declared.ShouldBe(expected,
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
$"is 0x{expected:X8}" +
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
: ". The declared value is not any OPC UA status code."));
}
/// <summary>
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
/// </summary>
[Fact]
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
{
var found = DeclaredStatusConstants();
found.Count.ShouldBeGreaterThan(20,
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
"or the naming convention changed");
var assemblies = found
.Select(row => row.Data.Item1)
.Distinct(StringComparer.Ordinal)
.ToArray();
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
}
@@ -12,6 +12,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3"/> <PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/> <PackageReference Include="Shouldly"/>
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/> <PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio"> <PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -35,6 +37,10 @@
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
puts it in StatusCodeParityTests' scope. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,180 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
/// transition (which for a still-active alarm with static dependencies may never come).
/// </summary>
/// <remarks>
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
/// report the state, and reading it does not fire an emission.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmProjectionTests
{
private static ScriptedAlarmDefinition Alarm(
string id = "HighTemp",
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
string msg = "Temp is {Temp}",
AlarmSeverity sev = AlarmSeverity.High) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate);
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
private static AlarmConditionState PersistedActive(
string alarmId = "HighTemp",
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
AlarmEnabledState.Enabled,
AlarmActiveState.Active,
acked,
AlarmConfirmedState.Confirmed,
ShelvingState.Unshelved,
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
[Fact]
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
{
var up = new FakeUpstream();
up.Set("Temp", 150); // predicate still true — no transition to report
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
var emissions = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
lock (emissions)
{
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
}
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.AlarmId.ShouldBe("HighTemp");
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
}
/// <summary>The projection carries the definition's severity and the message template resolved against
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
[Fact]
public async Task Projection_carries_definition_severity_and_a_resolved_message()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Severity.ShouldBe(AlarmSeverity.Critical);
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
}
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
/// or every deploy would append a duplicate historian / alerts row.</summary>
[Fact]
public async Task Reading_projections_never_fires_an_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var emissions = 0;
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
Volatile.Read(ref emissions).ShouldBe(0);
}
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
/// "nothing to restore" from "restore this".</summary>
[Fact]
public async Task A_normal_alarm_projects_the_no_event_position()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false
using var eng = Build(up, new InMemoryAlarmStateStore());
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
}
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
[Fact]
public async Task Projection_carries_the_last_observed_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
var deadline = DateTime.UtcNow.AddSeconds(10);
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
{
await Task.Delay(20, TestContext.Current.CancellationToken);
}
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
}
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
[Fact]
public void Projections_are_empty_before_the_first_load()
{
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
eng.GetProjections().ShouldBeEmpty();
}
}
@@ -10,59 +10,109 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary> /// <summary>
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We /// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We hold the dispatch loop
/// hold the dispatch loop with a slow handler so the channel fills, then verify /// with a blocking handler so the channel fills, then verify the producer keeps reading from the gw
/// the producer keeps reading from the gw stream and increments the /// stream and increments the <c>galaxy.events.dropped</c> counter rather than blocking.
/// <c>galaxy.events.dropped</c> counter rather than blocking.
/// </summary> /// </summary>
/// <remarks>
/// These tests observe two background loops (<c>RunAsync</c> / <c>DispatchLoopAsync</c>) through a
/// process-wide <see cref="Meter"/>, so every wait here is a <b>presence</b> assertion polled up to
/// <see cref="Budget"/> — never a fixed sleep. A fixed sleep is a bet that both loops get scheduled
/// within it, and #503 is what losing that bet looks like when the box is busy running other test
/// assemblies.
/// </remarks>
public sealed class EventPumpBoundedChannelTests public sealed class EventPumpBoundedChannelTests
{ {
/// <summary>Verifies that the event pump drops newest events when the bounded channel fills and records metrics for dropped events.</summary> /// <summary>Upper bound on how long a poll waits for a background loop to reach its terminal state.
/// Generous on purpose: it is spent only when something is genuinely broken, and it cannot turn a
/// failing assertion into a passing one.</summary>
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(15);
/// <summary>
/// With the dispatch loop genuinely held and the channel therefore full, the producer keeps
/// draining the gw stream and counts every rejected write on <c>galaxy.events.dropped</c> —
/// newest-dropped, not back-pressure.
/// </summary>
/// <remarks>
/// <para>
/// The arrangement is staged so the arithmetic is exact rather than scheduler-dependent
/// (#503): emit ONE event, wait until the dispatcher has actually entered the handler (so it
/// holds that event and the channel is empty), and only then emit the remaining nine. The
/// channel accepts <c>capacity</c> of them and the rest must be dropped —
/// <c>10 1 held 2 buffered = 7</c>, every run.
/// </para>
/// </remarks>
[Fact] [Fact]
public async Task Drops_newest_when_channel_fills_and_records_metric() public async Task Drops_newest_when_channel_fills_and_records_metric()
{ {
var counters = StartMeterCapture(); const int totalEvents = 10;
const int channelCapacity = 2;
// One event is held inside the handler; `channelCapacity` more fit in the channel; the rest are dropped.
const int expectedDropped = totalEvents - 1 - channelCapacity;
// Unique per run: the counters are filtered to this exact galaxy.client tag, which is what keeps a
// parallel sibling test's pump out of them (#503). A literal name would work today and break the
// day someone reuses it.
var clientName = $"PumpTest-{Guid.NewGuid():N}";
var counters = StartMeterCapture(clientName);
// A SYNCHRONOUS gate, deliberately. OnDataChange is EventHandler<T> — void-returning — so an
// `async (_, _) => await gate` lambda is async void: it returns to the dispatch loop at the first
// await and holds nothing. That is exactly what #503 was: the loop drained freely, drops happened
// only when the producer happened to outrun the consumer, and the assertions rode on scheduling
// luck. Blocking the loop's own thread is what makes "the dispatcher is held" true.
var dispatchGate = new ManualResetEventSlim(false);
var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
EventPump? pump = null;
try try
{ {
var subscriber = new ManualSubscriber(); var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry(); var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]); registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
// Tiny channel + slow handler ⇒ producer hits FullMode.Wait → TryWrite false pump = new EventPump(
// for every overflow event. subscriber, registry, channelCapacity: channelCapacity, clientName: clientName);
var dispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); pump.OnDataChange += (_, _) =>
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 2, clientName: "PumpTest");
pump.OnDataChange += async (_, _) =>
{ {
// Block the dispatch loop until we've shoved enough events through to handlerEntered.TrySetResult();
// overflow the bounded channel. Consume the gate exactly once. dispatchGate.Wait();
await dispatchGate.Task.ConfigureAwait(false);
}; };
pump.Start(); pump.Start();
const int totalEvents = 10; // Stage 1 — one event, then wait for the dispatcher to take it and block. After this the
for (var i = 0; i < totalEvents; i++) // channel is provably empty and exactly one event is in flight inside the handler.
await subscriber.EmitAsync(itemHandle: 7, value: 0);
await handlerEntered.Task.WaitAsync(Budget);
// Stage 2 — the rest. `channelCapacity` are accepted; the remainder must be dropped.
for (var i = 1; i < totalEvents; i++)
{ {
await subscriber.EmitAsync(itemHandle: 7, value: i); await subscriber.EmitAsync(itemHandle: 7, value: i);
} }
// Give the producer a beat to run TryWrite for every event.
await Task.Delay(150);
// Capacity 2 + 1 in-flight in the dispatcher = 3 may have been accepted; the // PRESENCE assertion on the discriminator (#500's rule): poll until the drop count reaches its
// remainder should have hit the dropped counter. Don't pin exact counts — // terminal value rather than sleeping a fixed 150 ms and hoping the producer got scheduled.
// the scheduler can interleave; pin the invariants instead. // The budget is an upper bound before giving up — spending it costs nothing on the happy path,
counters.Received.ShouldBeGreaterThanOrEqualTo(totalEvents); // and no amount of it can make a genuinely broken pump pass. Dropped starts at 0, so this
counters.Dropped.ShouldBeGreaterThan(0, // cannot be satisfied by the initial state.
"with capacity=2 and a held dispatcher we must drop at least one of 10 events"); await WaitUntilAsync(() => counters.Dropped == expectedDropped,
(counters.Received).ShouldBe(counters.Dispatched + counters.Dropped + counters.InFlight, $"expected {expectedDropped} dropped events");
"received = dispatched + dropped + (events still queued)");
// Release the dispatcher so DisposeAsync can drain cleanly. counters.Received.ShouldBe(totalEvents,
dispatchGate.TrySetResult(); "the producer must keep reading the gw stream through the overflow, not back-pressure");
counters.Dropped.ShouldBe(expectedDropped);
counters.Dispatched.ShouldBe(0,
"the dispatch loop is still blocked in the handler, so nothing has completed dispatch — " +
"if this is non-zero the handler is not actually holding the loop and the test proves nothing");
} }
finally finally
{ {
// Release BEFORE disposing: DisposeAsync awaits the dispatch loop, and that loop is parked on
// this gate. Releasing it here (not at the end of the try) is what keeps a failed assertion a
// failure instead of a hang.
dispatchGate.Set();
if (pump is not null) await pump.DisposeAsync();
dispatchGate.Dispose();
counters.Dispose(); counters.Dispose();
} }
} }
@@ -110,23 +160,21 @@ public sealed class EventPumpBoundedChannelTests
// Poll until at least one galaxy.events.received measurement tagged // Poll until at least one galaxy.events.received measurement tagged
// galaxy.client=Driver-X lands in the listener, rather than using a // galaxy.client=Driver-X lands in the listener, rather than using a
// fixed delay that races under parallel test load on a busy box. // fixed delay that races under parallel test load on a busy box.
var deadline = DateTime.UtcNow.AddSeconds(5); // Budget, not 5 s: the same contention that produced #503 stretches how long the
bool found = false; // producer loop takes to be scheduled, and a presence poll costs nothing to over-budget.
while (DateTime.UtcNow < deadline) await WaitUntilAsync(
{ () =>
listener.RecordObservableInstruments();
bool hasMatch;
lock (captured)
{ {
hasMatch = captured.Any(c => listener.RecordObservableInstruments();
c.Instrument == "galaxy.events.received" && lock (captured)
c.Tags.Any(t => t.Key == "galaxy.client" && {
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal))); return captured.Any(c =>
} c.Instrument == "galaxy.events.received" &&
if (hasMatch) { found = true; break; } c.Tags.Any(t => t.Key == "galaxy.client" &&
await Task.Delay(25); string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
} }
_ = found; // assertion happens below after dispose },
"a galaxy.events.received measurement tagged galaxy.client=Driver-X");
} }
// The static Meter is shared across all EventPump instances in the test // The static Meter is shared across all EventPump instances in the test
@@ -146,7 +194,46 @@ public sealed class EventPumpBoundedChannelTests
ours.ShouldContain(c => c.Instrument == "galaxy.events.received"); ours.ShouldContain(c => c.Instrument == "galaxy.events.received");
} }
private static CounterCapture StartMeterCapture() /// <summary>Polls <paramref name="condition"/> until it holds or <see cref="Budget"/> elapses, failing
/// with <paramref name="what"/> on timeout. The presence-assertion counterpart to a fixed delay.</summary>
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow + Budget;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10);
}
condition().ShouldBeTrue($"timed out after {Budget.TotalSeconds:0}s waiting: {what}");
}
/// <summary>
/// Captures the three EventPump counters for <b>one specific pump</b>, identified by its
/// <c>galaxy.client</c> tag.
/// </summary>
/// <remarks>
/// <para>
/// The <c>galaxy.client</c> filter is the fix for #503, and it is not optional. The pump's
/// <see cref="Meter"/> is <b>static</b> — one instance for the whole process — and
/// <c>InstrumentPublished</c> can only select by <i>meter</i>, so an unfiltered listener
/// receives measurements from every <see cref="EventPump"/> alive anywhere in the assembly.
/// xUnit runs test classes in parallel and several of them build pumps
/// (<c>EventPumpStreamFaultTests</c>, the <c>Driver-X</c> test below), so a sibling's events
/// landed in these counters — that is why the flake needed a busy box: it needed the two
/// tests to overlap in time. The leak was measured directly, as <c>Received = 11</c> in a run
/// that emitted 10.
/// </para>
/// <para>
/// The old <c>Received == Dispatched + Dropped + InFlight</c> assertion is what turned the
/// leak into a failure: a foreign increment landing between its four separate
/// <see cref="Interlocked.Read(ref long)"/> calls made the two sides disagree. Hence the
/// reported subject, <c>counters.Received</c>.
/// </para>
/// </remarks>
/// <param name="clientName">The <c>galaxy.client</c> tag value to accept. Must be unique to the
/// calling test — pass a fresh GUID-suffixed name rather than a literal, so a future sibling test
/// cannot silently reintroduce the leak by reusing the same name.</param>
private static CounterCapture StartMeterCapture(string clientName)
{ {
var capture = new CounterCapture(); var capture = new CounterCapture();
var listener = new MeterListener(); var listener = new MeterListener();
@@ -154,8 +241,20 @@ public sealed class EventPumpBoundedChannelTests
{ {
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr); if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
}; };
listener.SetMeasurementEventCallback<long>((instr, value, _, _) => listener.SetMeasurementEventCallback<long>((instr, value, tags, _) =>
{ {
var mine = false;
foreach (var tag in tags)
{
if (tag.Key == "galaxy.client" &&
string.Equals((string?)tag.Value, clientName, StringComparison.Ordinal))
{
mine = true;
break;
}
}
if (!mine) return;
switch (instr.Name) switch (instr.Name)
{ {
case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break; case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break;
@@ -178,8 +277,11 @@ public sealed class EventPumpBoundedChannelTests
public long Dispatched => Interlocked.Read(ref _dispatched); public long Dispatched => Interlocked.Read(ref _dispatched);
/// <summary>Gets the count of dropped events.</summary> /// <summary>Gets the count of dropped events.</summary>
public long Dropped => Interlocked.Read(ref _dropped); public long Dropped => Interlocked.Read(ref _dropped);
/// <summary>Gets the count of in-flight events.</summary> // There is deliberately no InFlight member. It could only be DERIVED as
public long InFlight => Math.Max(0, Received - Dispatched - Dropped); // Received - Dispatched - Dropped, which made the old
// `Received == Dispatched + Dropped + InFlight` assertion a tautology: it could not fail on a
// real defect, only on a torn read across the four separate Interlocked.Read calls. Assert the
// three measured counters directly instead (#503).
/// <summary>Disposes the meter listener.</summary> /// <summary>Disposes the meter listener.</summary>
public void Dispose() => Listener?.Dispose(); public void Dispose() => Listener?.Dispose();
} }
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC UA status code.</param> /// <param name="expected">The expected OPC UA status code.</param>
[Theory] [Theory]
[InlineData((byte)192, 0x00000000u)] // Good [InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride [InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain [InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad [InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
/// <param name="expected">The expected OPC DA category byte.</param> /// <param name="expected">The expected OPC DA category byte.</param>
[Theory] [Theory]
[InlineData(0x00000000u, (byte)192)] // Good [InlineData(0x00000000u, (byte)192)] // Good
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category [InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x40000000u, (byte)64)] // Uncertain [InlineData(0x40000000u, (byte)64)] // Uncertain
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category [InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x80000000u, (byte)0)] // Bad [InlineData(0x80000000u, (byte)0)] // Bad
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category [InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category [InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
{ {
[Theory] [Theory]
[InlineData(192, 0x00000000u)] // Good [InlineData(192, 0x00000000u)] // Good
[InlineData(216, 0x00D80000u)] // Good_LocalOverride [InlineData(216, 0x00960000u)] // Good_LocalOverride
[InlineData(64, 0x40000000u)] // Uncertain [InlineData(64, 0x40000000u)] // Uncertain
[InlineData(0, 0x80000000u)] // Bad [InlineData(0, 0x80000000u)] // Bad
[InlineData(8, 0x808A0000u)] // Bad_NotConnected [InlineData(8, 0x808A0000u)] // Bad_NotConnected
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
{ {
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) }; var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
var snap = SampleMapper.ToAggregateSnapshot(a); var snap = SampleMapper.ToAggregateSnapshot(a);
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
Assert.Null(snap.Value); Assert.Null(snap.Value);
} }
@@ -475,6 +475,99 @@ public sealed class SqlServerReadTests
// ---- helpers ---- // ---- helpers ----
// ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ----
/// <summary>
/// The gate's live proof: an authored column that does not exist is refused by the allow-list at
/// Initialize, so it never reaches a query — and its neighbour on the same table keeps reading.
/// </summary>
/// <remarks>
/// Only a real SQL Server exercises the real <c>SELECT SCHEMA_NAME()</c> + <c>INFORMATION_SCHEMA</c>
/// path the loader depends on; the SQLite-backed suites prove the logic but not that T-SQL's catalog
/// answers the shape the loader expects.
/// </remarks>
[Fact]
public async Task CatalogGate_realSqlServer_rejectsAnUnknownColumn_andSparesItsNeighbour()
{
SkipUnlessLive();
var bogus = Tag("Sql/Line1/Bogus", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
["keyColumn"] = SqlPollServerFixture.KeyColumn,
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = "no_such_column",
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
});
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), bogus);
// An authoring typo is not a database fault: the driver stays Healthy.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Bogus"], TestContext.Current.CancellationToken);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>
/// T-SQL object names are case-insensitive under the default collation, so a case-variant tag has
/// always been valid and must keep working — the gate substitutes the catalog's spelling rather than
/// rejecting the operator's.
/// </summary>
[Fact]
public async Task CatalogGate_realSqlServer_acceptsACaseVariantIdentifierAndStillReads()
{
SkipUnlessLive();
var upper = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable).ToUpperInvariant(),
["keyColumn"] = SqlPollServerFixture.KeyColumn.ToUpperInvariant(),
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = SqlPollServerFixture.ValueColumn.ToUpperInvariant(),
["timestampColumn"] = SqlPollServerFixture.TimestampColumn.ToUpperInvariant(),
});
await using var driver = await StartAsync(upper);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
}
/// <summary>
/// A cross-database name cannot be validated from this connection's catalog, so it is refused rather
/// than quoted into a query — the documented v1 limitation, asserted so it stays deliberate.
/// </summary>
[Fact]
public async Task CatalogGate_realSqlServer_rejectsAThreePartName()
{
SkipUnlessLive();
var threePart = Tag("Sql/Line1/Remote", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = $"otherdb.{SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable)}",
["keyColumn"] = SqlPollServerFixture.KeyColumn,
["keyValue"] = SqlPollServerFixture.PresentKey,
["valueColumn"] = SqlPollServerFixture.ValueColumn,
});
await using var driver = await StartAsync(threePart);
(await driver.ReadAsync(["Sql/Line1/Remote"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary> /// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive() private void SkipUnlessLive()
{ {
@@ -0,0 +1,354 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
/// of rejecting every tag.</para>
/// </summary>
public sealed class SqlCatalogGateDriverTests
{
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
/// turn a diagnosable Bad quality into a missing address-space entry.
/// </summary>
[Fact]
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The driver is healthy: an authoring typo is not a database fault.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
// The node is still materialized...
var capture = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
[Fact]
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
}
/// <summary>
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
/// query that failed only once the connection was open; now it never reaches a query at all.
/// </summary>
[Fact]
public async Task A_hostile_table_name_never_reaches_a_query()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger,
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
(await driver.ReadAsync(["Evil"], CancellationToken.None))
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The fixture's table is untouched — proven by a second tag still reading through it.
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
logger.Entries.ShouldContain(e =>
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
}
/// <summary>
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
/// field and the reason.
/// </summary>
[Fact]
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
{
using var fixture = new SqlitePollFixture();
var logger = new CapturingLogger();
await using var driver = NewDriver(
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var warning = logger.Entries
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ShouldHaveSingleItem();
warning.ShouldContain("Bogus");
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
warning.ShouldContain("num_valeu");
}
/// <summary>
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
/// catalog strings rather than operator input.
/// </summary>
[Fact]
public async Task A_case_variant_identifier_is_accepted_and_polls()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry(
"Speed", SqlitePollFixture.PresentKey,
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
}
/// <summary>
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
/// be a round-trip that can only fail.
/// </summary>
[Fact]
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
/// </summary>
[Fact]
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT this is not valid sql"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
var thrown = await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
// The operator surface names the stage that actually failed — "reached it but could not read the
// catalog" and "could not reach it" send an operator to different systems.
thrown.Message.ShouldContain("catalog");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
/// every tag for an authoring fault the operator does not have.
/// </summary>
[Fact]
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
{
using var fixture = new SqlitePollFixture();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await Should.ThrowAsync<InvalidOperationException>(
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
// ---- helpers ----
/// <summary>
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
/// catalog-load failure modes can be driven against an otherwise-real dialect.
/// </summary>
/// <remarks>
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
/// would silently not happen and both tests below would pass for the wrong reason.
/// </remarks>
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
public SqlProvider Provider => Inner.Provider;
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
public string LivenessSql => Inner.LivenessSql;
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
public string ListSchemasSql { get; } = listSchemasSql;
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
public string ListTablesSql => Inner.ListTablesSql;
public string ListColumnsSql => Inner.ListColumnsSql;
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
}
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
=> NewDriver(fixture, new CapturingLogger(), rawTags);
private static SqlDriver NewDriver(
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
=> new(
new SqlDriverOptions
{
RawTags = rawTags,
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
new SqliteDialect(),
fixture.ConnectionString,
factory: fixture.Factory,
logger: logger);
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
private static RawTagEntry KvEntry(
string rawPath,
string keyValue,
string? table = null,
string? valueColumn = null)
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
{
"driver": "Sql",
"model": "KeyValue",
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
"keyValue": "{{keyValue}}",
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
}
"""), WriteIdempotent: false);
/// <summary>Records everything the driver streams into the address space.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
/// <summary>The variables registered, in order.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullReference) : IVariableHandle
{
public string FullReference => fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
/// <summary>Records every log record, level + rendered message.</summary>
private sealed class CapturingLogger : ILogger<SqlDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,316 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built
/// <see cref="SqlCatalog"/>. The end-to-end behaviour against a real catalog — and the proof that a
/// rejected tag keeps its node and publishes <c>BadNodeIdUnknown</c> — lives in
/// <see cref="SqlCatalogGateDriverTests"/>.
/// </summary>
public sealed class SqlCatalogGateTests
{
private static readonly SqliteDialect Dialect = new();
/// <summary>A catalog with one schema, two relations, and deliberately mixed-case column spellings.</summary>
private static SqlCatalog Catalog(string defaultSchema = "dbo") => new(
defaultSchema,
["dbo", "mes"],
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo"] = ["TagValues", "LatestStatus"],
["mes"] = ["Orders"],
},
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"],
["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"],
["mes.Orders"] = ["order_id", "qty"],
});
private static SqlTagDefinition KeyValueTag(
string table = "dbo.TagValues",
string keyColumn = "tag_name",
string valueColumn = "num_value",
string? timestampColumn = "sample_ts") =>
new("plant/sql/Speed", SqlTagModel.KeyValue, table,
KeyColumn: keyColumn, KeyValue: "Line1.Speed",
ValueColumn: valueColumn, TimestampColumn: timestampColumn);
private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) =>
SqlCatalogGate.Apply(tags, Catalog(), Dialect);
[Fact]
public void A_fully_resolvable_tag_is_accepted()
{
var result = Apply(KeyValueTag());
result.Rejected.ShouldBeEmpty();
result.Accepted.Count.ShouldBe(1);
}
/// <summary>
/// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an
/// operator typed. Authoring every identifier in the wrong case proves the substitution actually
/// happens rather than the input merely being waved through.
/// </summary>
[Fact]
public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling()
{
var authored = KeyValueTag(
table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS");
var accepted = Apply(authored).Accepted.ShouldHaveSingleItem();
accepted.Table.ShouldBe("dbo.TagValues");
accepted.KeyColumn.ShouldBe("tag_name");
accepted.ValueColumn.ShouldBe("num_value");
accepted.TimestampColumn.ShouldBe("sample_ts");
// Identity and bound VALUES are untouched — the gate rewrites identifiers only.
accepted.Name.ShouldBe(authored.Name);
accepted.KeyValue.ShouldBe(authored.KeyValue);
}
[Fact]
public void An_unqualified_table_resolves_in_the_default_schema()
{
var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem();
accepted.Table.ShouldBe("dbo.TagValues");
}
/// <summary>
/// Guessing <c>dbo</c> would be a silent lie on an estate that maps service accounts to their own
/// default schema, so the gate must resolve an unqualified name in whatever schema the server reports.
/// </summary>
[Fact]
public void An_unqualified_table_follows_a_non_dbo_default_schema()
{
var catalog = Catalog(defaultSchema: "mes");
var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect);
result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders");
}
[Fact]
public void An_unknown_table_rejects_the_tag()
{
var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem();
rejection.RawPath.ShouldBe("plant/sql/Speed");
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("NoSuchTable");
}
[Fact]
public void An_unknown_schema_rejects_the_tag()
{
var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("nope");
}
[Fact]
public void An_unknown_column_rejects_the_tag_and_names_the_field()
{
var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
rejection.Reason.ShouldContain("no_such_column");
rejection.Reason.ShouldContain("dbo.TagValues");
}
/// <summary>
/// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would
/// allow-list a column set from a relation the query will never read.
/// </summary>
[Fact]
public void A_table_from_another_schema_does_not_resolve_unqualified()
{
var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null))
.Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
}
/// <summary>
/// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it
/// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead.
/// </summary>
[Fact]
public void A_three_part_name_is_rejected_with_an_actionable_message()
{
var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
rejection.Reason.ShouldContain("3-part");
rejection.Reason.ShouldContain("view");
}
/// <summary>
/// The injection shape the whole gate exists for: a hostile identifier must be refused by the
/// allow-list, not merely quoted into a query against a nonexistent object.
/// </summary>
[Theory]
[InlineData("TagValues]; DROP TABLE TagValues--")]
[InlineData("'; DROP TABLE TagValues--")]
[InlineData("TagValues WHERE 1=1 OR 1=1")]
public void A_hostile_table_name_is_rejected_by_the_allow_list(string table)
{
Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.Table));
}
[Theory]
[InlineData("num_value]; DROP TABLE TagValues--")]
[InlineData("(SELECT password FROM users)")]
public void A_hostile_column_name_is_rejected_by_the_allow_list(string column)
{
Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
}
/// <summary>
/// A name carrying a control or Unicode format character cannot be safely rendered into a log line
/// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it <em>without echoing it</em> — the
/// charset check runs before the catalog lookup precisely so no such string can reach a message.
/// </summary>
/// <remarks>
/// Driven against <see cref="SqlServerDialect"/>, not the test-only <see cref="SqliteDialect"/>: the
/// charset rules belong to the dialect (the gate delegates to
/// <see cref="ISqlDialect.QuoteIdentifier"/> rather than duplicating them), and SQLite's rules
/// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's
/// dialect — the first draft of this test asserted them against SQLite's and failed for that reason.
/// </remarks>
[Theory]
[InlineData("num\u0000value")] // Cc — embedded NUL
[InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement
[InlineData("num\u202Evalue")] // Cf — right-to-left override
[InlineData("num\u200Bvalue")] // Cf — zero-width space
public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column)
{
var result = SqlCatalogGate.Apply(
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
var rejection = result.Rejected.ShouldHaveSingleItem();
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
rejection.Reason.ShouldContain("withheld");
rejection.Reason.ShouldNotContain(column);
}
/// <summary>
/// An over-long name cannot name a real object and is likewise withheld, rather than pasting an
/// unbounded slab of operator input into a log line.
/// </summary>
[Fact]
public void An_over_long_identifier_is_rejected_without_being_echoed()
{
var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1);
var result = SqlCatalogGate.Apply(
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld");
}
/// <summary>
/// The complement, and the reason the charset check is not simply "withhold everything": a name that
/// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote.
/// </summary>
[Fact]
public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable()
{
var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem();
rejection.Reason.ShouldContain("num_valeu");
rejection.Reason.ShouldNotContain("withheld");
}
/// <summary>
/// On a case-sensitive collation a relation may legitimately carry both <c>Value</c> and <c>value</c>.
/// Picking one would publish a different column's data under the operator's node, so the only safe
/// answer is to refuse — but an EXACT match must still win, or a valid config would break.
/// </summary>
[Fact]
public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins()
{
var catalog = new SqlCatalog(
"dbo",
["dbo"],
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["T"] },
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
{
["dbo.T"] = ["k", "Value", "value"],
});
var ambiguous = new SqlTagDefinition(
"p/Ambiguous", SqlTagModel.KeyValue, "dbo.T",
KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE");
SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" };
SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem()
.ValueColumn.ShouldBe("value");
}
/// <summary>
/// One operator typo must not stop the other tags on the same database — the same rule the tag-table
/// build already follows for a malformed blob.
/// </summary>
[Fact]
public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it()
{
var good = KeyValueTag() with { Name = "plant/sql/Good" };
var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" };
var result = Apply(good, bad);
result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good");
result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad");
}
/// <summary>Every identifier-bearing field is checked, not just the two the key-value model happens to use.</summary>
[Fact]
public void The_wide_row_models_identifier_fields_are_validated_too()
{
var selectorTypo = new SqlTagDefinition(
"p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7");
Apply(selectorTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn));
var orderTypo = new SqlTagDefinition(
"p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts");
Apply(orderTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp));
var columnTypo = new SqlTagDefinition(
"p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7");
Apply(columnTypo).Rejected.ShouldHaveSingleItem()
.Field.ShouldBe(nameof(SqlTagDefinition.ColumnName));
var ok = new SqlTagDefinition(
"p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus",
ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7");
var accepted = Apply(ok).Accepted.ShouldHaveSingleItem();
accepted.ColumnName.ShouldBe("oven_temp");
accepted.RowSelectorColumn.ShouldBe("station_id");
accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized
}
/// <summary>An absent optional identifier is not a rejection — only a present, unresolvable one is.</summary>
[Fact]
public void An_absent_optional_timestamp_column_is_left_alone()
{
var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem();
accepted.TimestampColumn.ShouldBeNull();
}
}
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes /// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag /// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
/// cannot alter the database; the seed table is intact after every hostile poll. /// cannot alter the database; the seed table is intact after every hostile poll.
/// <para><b>Scope note — what this suite does NOT assume.</b> Design §8.1 also specifies a catalog gate: /// <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
/// validate every authored table/column against <c>INFORMATION_SCHEMA</c> before quoting it, so an unknown /// §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
/// identifier <em>rejects the tag</em>. <b>No such gate exists in the driver yet</b> (see the "Not yet /// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
/// implemented" note on <see cref="ISqlDialect"/>), and no task in this workstream builds one. So a hostile /// is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
/// identifier here is <b>not</b> rejected as <c>BadNodeIdUnknown</c> — it is bracket-/double-quoted into a /// <c>SqlCatalogGateDriverTests</c>.</para>
/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes /// <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
/// as a query failure (<see cref="SqlStatusCodes.BadCommunicationError"/>). This suite proves the payload /// definitions that never pass through the gate, and that is the point: defence in depth is only worth
/// is <em>inert</em> — it does not execute and the table survives — which is the guarantee the code /// the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be /// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
/// asserting fiction.</para> /// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
/// (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
/// expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
/// single point of failure.</para>
/// </summary> /// </summary>
public sealed class SqlInjectionRegressionTests public sealed class SqlInjectionRegressionTests
{ {
@@ -68,9 +71,11 @@ public sealed class SqlInjectionRegressionTests
using var fixture = new SqlitePollFixture(); using var fixture = new SqlitePollFixture();
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
// A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not // A table name carrying a statement terminator + DROP. The reader is driven directly, so the
// rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table), // catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
// the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs. // nonexistent identifier, the query fails (no such table) with the connection already open, so the
// tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
// this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
var reader = NewReader(fixture, var reader = NewReader(fixture,
new SqlTagDefinition( new SqlTagDefinition(
Name: "Evil", Name: "Evil",
@@ -83,7 +88,7 @@ public sealed class SqlInjectionRegressionTests
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
// Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate). // Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched. // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
@@ -64,6 +64,12 @@ public sealed class SqliteDialect : ISqlDialect
/// </summary> /// </summary>
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA"; public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
/// <summary>
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
/// </summary>
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
/// <summary> /// <summary>
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the /// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the /// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
/// </summary>
/// <remarks>
/// The bug being pinned: a range operator over an unvalidated DB string throws
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
/// that matter; the happy path is almost incidental.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DisplayTextTests
{
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
/// case for a 64-char SHA-256.</summary>
[Fact]
public void A_long_value_is_truncated_and_marked()
{
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
}
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
[Theory]
[InlineData("h1", 12)]
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
[InlineData("abc", 16)]
[InlineData("x", 64)]
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
{
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
}
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
[Fact]
public void A_short_value_gets_no_ellipsis()
{
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
}
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
/// marked.</summary>
[Fact]
public void A_value_exactly_at_the_budget_is_untouched()
{
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
}
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
/// producing a stray ellipsis.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_null_or_blank_value_renders_the_placeholder(string? value)
{
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
}
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
/// become a page crash, which is the whole point of this helper.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
{
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
}
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
/// page. Includes the shapes that broke the old code.</summary>
[Fact]
public void It_never_throws_for_any_combination()
{
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
foreach (var value in values)
{
foreach (var budget in budgets)
{
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
}
}
}
}
@@ -86,8 +86,19 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice")); actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack. // POSITIVE CONTROL, not a sleep (#501). `Acks` is empty at t=0, so any assertion made before the
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); // host has processed the message above passes without testing anything — and AwaitAssert returns on
// its FIRST success, so it spends none of its duration and is the wrong tool for an absence.
//
// Instead send a MAPPED ack afterwards. The host processes its mailbox in order and forwards to the
// child via Tell, and the child handles RouteAlarmAck with ReceiveAsync (which suspends its own
// mailbox), so by the time this one reaches the driver the unmapped one has provably already been
// handled. Whatever `Acks` holds then is the complete answer.
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
recorder.Acks.Count.ShouldBe(1,
"only the control ack may reach the driver — the unmapped condition NodeId must have been dropped");
} }
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the /// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
@@ -110,10 +121,25 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
}, },
CorrelationId.NewId())); CorrelationId.NewId()));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice")); actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "gated", "alice"));
// No ack reached the driver — the gate short-circuited before the inverse-map lookup. // POSITIVE CONTROL (#501) — same reasoning as the unmapped-node test, with the role itself as the
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); // control: return the node to PRIMARY and re-send the SAME mapped ack. Mailbox ordering means that
// once this one reaches the driver, the Secondary-gated one has already been processed. The
// discriminator is the comment, so a leaked ack is visible rather than merged into the count.
actor.Tell(new RedundancyStateChanged(
new[]
{
new NodeRedundancyState(TestNode, RedundancyRole.Primary,
IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow),
},
CorrelationId.NewId()));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
recorder.Acks.ShouldNotContain(a => a.Comment == "gated",
"the Primary gate must have dropped the ack sent while this node was Secondary");
recorder.Acks.Count.ShouldBe(1);
} }
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits /// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
@@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Writes.Count.ShouldBe(1); recorder.Writes.Count.ShouldBe(1);
@@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder); var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe(); RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Writes.Count.ShouldBe(1); recorder.Writes.Count.ShouldBe(1);
@@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
}, duration: Timeout); }, duration: Timeout);
} }
/// <summary>
/// Routes a write, retrying until the host accepts it, and fails with the host's own rejection
/// <c>Reason</c> if it never does.
/// </summary>
/// <remarks>
/// <para><b>Why a retry rather than a single Tell.</b> <c>ApplyAck</c> — which
/// <see cref="SpawnHostAndApply"/> waits for — marks the end of the <em>apply</em>, not the point at
/// which a write can succeed. Several things still have to happen after it: the spawned child has to
/// finish <c>InitializeAsync</c> and leave <c>Connecting</c> (that state deliberately fast-fails
/// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that
/// resolves the write. Telling the write immediately therefore races the setup, which is why this
/// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60
/// consecutive runs of this class alone.</para>
/// <para><b>Why retrying does not weaken the assertion.</b> Every rejection branch — the Primary
/// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected
/// fast-fail — replies <em>without</em> reaching the driver. A rejected attempt records nothing, so
/// the caller's <c>Writes.Count.ShouldBe(1)</c> still means exactly what it did before: the accepted
/// write reached the driver exactly once.</para>
/// <para>The failure message carries the last <c>Reason</c> so a genuine regression is diagnosable
/// rather than surfacing as a bare "expected True".</para>
/// </remarks>
/// <param name="actor">The driver-host actor.</param>
/// <param name="nodeId">The ns-qualified NodeId string, as the node manager passes it.</param>
/// <param name="value">The value to write.</param>
/// <param name="realm">The address-space realm the NodeId belongs to.</param>
private void RouteWriteUntilAccepted(
IActorRef actor, string nodeId, object value, AddressSpaceRealm realm)
{
var lastReason = "(no reply)";
AwaitAssert(
() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(1));
lastReason = result.Reason ?? "(none)";
result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}");
},
duration: Timeout,
interval: TimeSpan.FromMilliseconds(50));
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver /// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary> /// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact] [Fact]
@@ -0,0 +1,122 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides
/// <see cref="DriverInstanceActor"/>'s existing <c>health-poll</c> tick.
/// </summary>
/// <remarks>
/// <para>
/// The hole it closes: the desired ref set is re-applied on a <b>Connected transition</b> and on a
/// deploy's <c>SetDesiredSubscriptions</c>, and nowhere else. A subscription lost while the driver
/// <i>stayed</i> Connected — the shape reproduced here, a subscribe that failed and was never
/// retried — left the actor permanently subscribed to nothing while reporting Healthy.
/// </para>
/// <para>
/// All three tests drive a short <c>healthPollInterval</c> rather than the production 30 s. The
/// two absence assertions are bounded by a positive control (a counted number of ticks provably
/// processed), never by a bare sleep.
/// </para>
/// </remarks>
public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100);
/// <summary>
/// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set
/// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second
/// subscribe can only come from the periodic reconcile.
/// </summary>
[Fact]
public void A_lost_subscription_is_re_established_while_still_connected()
{
var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// The connect-path subscribe runs and throws, so the handle stays null.
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
// Only the reconcile can produce this second attempt — and it must succeed this time.
AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget);
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" });
}
/// <summary>
/// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which
/// would churn the backend every 30 s in production for no reason.
/// </summary>
[Fact]
public void A_healthy_subscription_is_not_re_subscribed_on_every_tick()
{
var publisher = new CountingHealthPublisher();
var driver = new SubscribableStubDriver();
var actor = Sys.ActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
// POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the
// publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what
// makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer
// had not fired yet" — the latter would pass instantly and prove nothing.
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
driver.SubscribeCount.ShouldBe(1,
"the handle is live, so the reconcile must leave it alone");
}
/// <summary>
/// A driver that is not <see cref="ISubscribable"/> can still be handed a desired set. Reconciling
/// it would tell <c>Subscribe</c> every tick and fail every tick, forever — so the reconcile is
/// gated on the capability.
/// </summary>
[Fact]
public void A_non_subscribable_driver_is_never_reconciled()
{
var publisher = new CountingHealthPublisher();
var driver = new StubDriver(); // IDriver only — no ISubscribable
var actor = Sys.ActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Same positive control: assert the reconcile stays silent across several PROCESSED ticks.
// EventFilter bounds the absence to this block, and the reconcile announces itself at Info —
// the first test above is the proof that this message does appear when the reconcile fires.
EventFilter.Info(contains: "periodic reconcile").Expect(0, () =>
{
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
});
}
/// <summary>Counts health-snapshot publishes, which is one per <c>health-poll</c> tick once the actor
/// is running — the observable that turns "time passed" into "ticks were processed".</summary>
private sealed class CountingHealthPublisher : IDriverHealthPublisher
{
private int _count;
/// <summary>Number of snapshots published so far.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
=> Interlocked.Increment(ref _count);
}
}
@@ -88,7 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
[Fact] [Fact]
public async Task Write_propagates_status_code_on_Bad_result() public async Task Write_propagates_status_code_on_Bad_result()
{ {
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b const uint badStatus = 0x80340000; // BadNodeIdUnknown — top severity bits = 10b
var driver = new WritableStubDriver { NextStatusCode = badStatus }; var driver = new WritableStubDriver { NextStatusCode = badStatus };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
@@ -114,6 +114,18 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary> /// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
public bool UnsubscribeYields { get; set; } public bool UnsubscribeYields { get; set; }
/// <summary>
/// How many of the first <see cref="SubscribeAsync"/> calls throw before one succeeds. Default 0
/// (always succeed) leaves existing behaviour untouched.
/// </summary>
/// <remarks>
/// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and
/// holds a desired ref set, but the subscribe that should have established the handle failed, so
/// <c>_subscriptionHandle</c> is null and nothing else will ever re-assert it — there is no
/// connect transition coming, and no deploy.
/// </remarks>
public int SubscribeFailuresBeforeSuccess { get; set; }
/// <summary>Subscribes to the specified full references.</summary> /// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param> /// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param> /// <param name="publishingInterval">The publishing interval.</param>
@@ -121,8 +133,13 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
public Task<ISubscriptionHandle> SubscribeAsync( public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{ {
Interlocked.Increment(ref SubscribeCount); var attempt = Interlocked.Increment(ref SubscribeCount);
LastSubscribedRefs = fullReferences; LastSubscribedRefs = fullReferences;
if (attempt <= SubscribeFailuresBeforeSuccess)
{
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
}
return Task.FromResult<ISubscriptionHandle>(_handle); return Task.FromResult<ISubscriptionHandle>(_handle);
} }
@@ -11,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// </summary> /// </summary>
public abstract class RuntimeActorTestBase : TestKit public abstract class RuntimeActorTestBase : TestKit
{ {
/// <summary>
/// Shared upper bound for a <b>presence</b> wait (<c>AwaitAssert</c> / <c>AwaitCondition</c>).
/// </summary>
/// <remarks>
/// <para><b>A budget, not a delay.</b> These helpers poll and return the instant the condition holds,
/// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and
/// <em>cannot</em> make a genuinely failing assertion pass — it only changes how quickly a real
/// breakage is reported. That is the opposite of an <b>absence</b> window (<c>ExpectNoMsg</c>,
/// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on
/// every run. Absence windows keep their own short, individually-calibrated literals and must not be
/// routed through here.</para>
/// <para><b>Why it exists (Gitea #500).</b> This assembly runs 44 Akka test classes roughly 14-way
/// parallel, each with its own <see cref="TestKit"/> ActorSystem. Budgets of 300500 ms sized on an
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
/// 30-run verification rounds each surfaced a <em>different</em> test failing on one, in three
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.</para>
/// </remarks>
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary> /// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
protected static string AkkaTestHocon => @" protected static string AkkaTestHocon => @"
akka { akka {
@@ -57,7 +57,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Adm, RedundancyRole.Detached))); State(Adm, RedundancyRole.Detached)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies the child for a departed peer is stopped when the next snapshot omits it.</summary> /// <summary>Verifies the child for a departed peer is stopped when the next snapshot omits it.</summary>
@@ -71,14 +71,16 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies a single-node snapshot (just the local node) spawns no children.</summary> /// <summary>Verifies a single-node snapshot (just the local node) spawns no children — the local node
/// is never its own probe peer. Established against a following two-node snapshot as a positive
/// control, so the count of 1 (rather than 2) is what carries the "no local child" claim.</summary>
[Fact] [Fact]
public void Single_node_snapshot_spawns_no_children() public void Single_node_snapshot_spawns_no_children()
{ {
@@ -87,8 +89,21 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), // POSITIVE CONTROL (#501). This is an ABSENCE assertion, and ChildCount is 0 before the snapshot
duration: TimeSpan.FromMilliseconds(500)); // above has been processed at all — so asserting it directly passes at t=0 and proves nothing
// (AwaitAssert returns on its first success, spending none of its duration). Unlike the other
// ChildCount.ShouldBe(0) waits in this class, there is no preceding ShouldBe(1) to make it a
// transition.
//
// Follow with a snapshot that DOES spawn exactly one child. The supervisor processes its mailbox
// in order, so once the count reaches 1 the single-node snapshot has provably been handled — and
// had it spawned a child for the local node, the count would be 2.
sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: PresenceBudget);
} }
/// <summary>Verifies a previously-removed peer is respawned when it re-appears, without an /// <summary>Verifies a previously-removed peer is respawned when it re-appears, without an
@@ -103,17 +118,17 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
sup.Tell(Snapshot( sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Locks in the stale-Terminated guard: when an OLD (already-replaced) child's /// <summary>Locks in the stale-Terminated guard: when an OLD (already-replaced) child's
@@ -132,27 +147,27 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(1), duration: PresenceBudget);
var oldRef = spawned[0]; var oldRef = spawned[0];
// Drop the peer -> child #0 stopped, ChildCount back to 0. // Drop the peer -> child #0 stopped, ChildCount back to 0.
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
// Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned. // Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned.
sup.Tell(Snapshot( sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary), State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary))); State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(2), duration: PresenceBudget);
// Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh // Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh
// child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1. // child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1.
sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false)); sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
} }
@@ -295,10 +295,24 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
// The first drain returns false (entry retained); after the backoff the retry drain succeeds // The first drain returns false (entry retained); after the backoff the retry drain succeeds
// and acks, truncating the outbox to 0. // and acks, truncating the outbox to 0.
await AwaitAssertAsync(async () => //
Assert.Equal(0, await outbox.CountAsync(default)), TimeSpan.FromSeconds(5)); // BOTH conditions are polled together, and the retry count is the load-bearing one. An empty
// outbox is NOT a distinguishing observation: it is equally true BEFORE the append lands, so
Assert.True(writer.CallCount >= 2, "the writer must have been called at least twice (a retry happened)"); // waiting on it alone can be satisfied by the initial state and return before the recorder has
// done anything at all. That is exactly what happened intermittently under a fully parallel
// assembly run — the poll won the race against the first append, the wait returned immediately,
// and the CallCount check that used to sit outside the block then failed against a recorder that
// had not yet run. (The same trap is called out on the preceding test, which guards against it by
// pairing its count with a writer observation.)
await AwaitAssertAsync(
async () =>
{
Assert.True(
writer.CallCount >= 2,
"the writer must have been called at least twice (a retry happened)");
Assert.Equal(0, await outbox.CountAsync(default));
},
TimeSpan.FromSeconds(5));
} }
[Fact] [Fact]
@@ -23,9 +23,27 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
/// <summary>The local node id the gating tests construct the adapter with.</summary> /// <summary>The local node id the gating tests construct the adapter with.</summary>
private static readonly NodeId LocalNode = new("node-A"); private static readonly NodeId LocalNode = new("node-A");
/// <summary>A short window we allow the fire-and-forget enqueue to land within.</summary> /// <summary>
/// The window an <b>absence</b> assertion waits before concluding nothing arrived
/// (<c>ExpectNoMsg</c>). Its length is a calibration decision — long enough that a message which
/// was going to arrive would have — so it is deliberately NOT generous.
/// </summary>
private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500);
/// <summary>
/// The budget a <b>presence</b> assertion may take to become true (<c>AwaitAssert</c>).
/// </summary>
/// <remarks>
/// Separate from <see cref="Settle"/> on purpose, though they once shared its 500 ms. The two are
/// different quantities that merely had the same number: a presence budget is an upper bound before
/// giving up, and <c>AwaitAssert</c> returns the instant the condition holds, so a generous value
/// costs nothing in the passing case and can never make a genuinely failing assertion pass. An
/// absence window is the opposite — every millisecond is spent on every run. Conflating them meant
/// the enqueue assertions could only be given more headroom by slowing every <c>ExpectNoMsg</c> in
/// the class, so they kept a 500 ms budget that a fully parallel assembly run occasionally missed.
/// </remarks>
private static readonly TimeSpan AssertTimeout = TimeSpan.FromSeconds(5);
/// <summary>Thread-safe fake sink that records every <see cref="EnqueueAsync"/> call.</summary> /// <summary>Thread-safe fake sink that records every <see cref="EnqueueAsync"/> call.</summary>
private sealed class RecordingSink : IAlarmHistorianSink private sealed class RecordingSink : IAlarmHistorianSink
{ {
@@ -101,7 +119,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
actor.Tell(SampleEvent()); actor.Tell(SampleEvent());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Secondary suppression (T2): when the cached local role is Secondary, the adapter MUST /// <summary>Secondary suppression (T2): when the cached local role is Secondary, the adapter MUST
@@ -145,7 +163,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
TellRedundancyRole(actor, RedundancyRole.Primary); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleEvent()); actor.Tell(SampleEvent());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Absent-node default-historize (T5): a snapshot that mentions only a DIFFERENT node /// <summary>Absent-node default-historize (T5): a snapshot that mentions only a DIFFERENT node
@@ -174,7 +192,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
actor.Tell(SampleEvent()); actor.Tell(SampleEvent());
// Local role is still unknown ⇒ default-historize path: sink must record exactly one enqueue. // Local role is still unknown ⇒ default-historize path: sink must record exactly one enqueue.
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Builds an <see cref="AlarmTransitionEvent"/> (the shape published on the <c>alerts</c> /// <summary>Builds an <see cref="AlarmTransitionEvent"/> (the shape published on the <c>alerts</c>
@@ -222,7 +240,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
e.Severity.ShouldBe(AlarmSeverity.High); e.Severity.ShouldBe(AlarmSeverity.High);
e.Comment.ShouldBe("note"); e.Comment.ShouldBe("note");
}, },
Settle); AssertTimeout);
} }
/// <summary>Secondary suppression for alerts (T7): a Secondary node must NOT historize a transition /// <summary>Secondary suppression for alerts (T7): a Secondary node must NOT historize a transition
@@ -252,7 +270,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
TellRedundancyRole(actor, RedundancyRole.Primary); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleTransition()); actor.Tell(SampleTransition());
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose /// <summary>Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose
@@ -286,7 +304,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
TellRedundancyRole(actor, RedundancyRole.Primary); TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleTransition(historizeToAveva: null)); actor.Tell(SampleTransition(historizeToAveva: null));
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout);
} }
/// <summary>Severity buckets (T9): the OPC UA 11000 numeric severity on the transition maps onto /// <summary>Severity buckets (T9): the OPC UA 11000 numeric severity on the transition maps onto
@@ -308,7 +326,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
AwaitAssert( AwaitAssert(
() => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected), () => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected),
Settle); AssertTimeout);
} }
/// <summary>Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON /// <summary>Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON
@@ -326,6 +344,6 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
AwaitAssert( AwaitAssert(
() => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"), () => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"),
Settle); AssertTimeout);
} }
} }
@@ -48,11 +48,16 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
// A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls
// and returns the instant the meter fires, so a generous value costs nothing on the happy path and
// can never make a genuinely failing assertion pass. Two seconds covered an idle machine but not a
// fully parallel assembly run, where this deploy→rebuild→throw chain (two DB-backed applies) has to
// share 14 cores with 40-odd other Akka test classes; it failed there roughly 1 run in 30.
AwaitAssert(() => AwaitAssert(() =>
{ {
recorder.Total.ShouldBeGreaterThanOrEqualTo(1); recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1); recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1);
}, duration: TimeSpan.FromSeconds(2)); }, duration: TimeSpan.FromSeconds(15));
} }
/// <summary>A clean rebuild does NOT increment the apply-failed meter (Info-only happy path).</summary> /// <summary>A clean rebuild does NOT increment the apply-failed meter (Info-only happy path).</summary>
@@ -45,7 +45,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink.Calls.ShouldContain("EF:eq-1"); sink.Calls.ShouldContain("EF:eq-1");
sink.Calls.ShouldContain("EF:eq-2"); sink.Calls.ShouldContain("EF:eq-2");
sink.Calls.ShouldContain("NA:line-1"); sink.Calls.ShouldContain("NA:line-1");
}, duration: TimeSpan.FromSeconds(2)); }, duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
} }
@@ -85,7 +85,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink: sink, dbFactory: db, applier: applier)); sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild
var callsAfterFirst = sink.Calls.Count; var callsAfterFirst = sink.Calls.Count;
@@ -107,7 +107,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary> /// <summary>
@@ -132,7 +132,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact));
// The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised… // The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised…
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget);
// …and the address-space-wiping raw-sink fallback was NOT taken. // …and the address-space-wiping raw-sink fallback was NOT taken.
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
} }
@@ -176,7 +176,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary> /// <summary>
@@ -197,7 +197,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
} }
@@ -230,7 +230,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
// PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice. // PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice.
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1".
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: PresenceBudget);
sinkA.RebuildCalls.ShouldBe(0); sinkA.RebuildCalls.ShouldBe(0);
// t-main (MAIN cluster) must NOT leak onto the SITE-A node. // t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
@@ -249,7 +249,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: PresenceBudget);
sinkM.RebuildCalls.ShouldBe(0); sinkM.RebuildCalls.ShouldBe(0);
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
} }
@@ -331,7 +331,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
var calls = sink.Calls.ToList(); var calls = sink.Calls.ToList();
@@ -356,7 +356,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:"));
@@ -365,7 +365,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
// No new NodeAdded announcement was raised for the rebuild-kind deploy. // No new NodeAdded announcement was raised for the rebuild-kind deploy.
sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst);
} }
@@ -388,7 +388,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget);
var callsAfterFirst = sink.Calls.Count; var callsAfterFirst = sink.Calls.Count;
// The next deploy arrives while the ConfigDb is briefly unreachable. // The next deploy arrives while the ConfigDb is briefly unreachable.
@@ -423,13 +423,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget);
// eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply.
var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget);
} }
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> can be made to /// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> can be made to
@@ -552,7 +552,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
// First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd
// (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly.
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); sink.RebuildCalls.ShouldBe(0);
// Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and
@@ -563,7 +563,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
AwaitAssert(() => AwaitAssert(() =>
{ {
sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South")); sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South"));
}, duration: TimeSpan.FromSeconds(2)); }, duration: PresenceBudget);
sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild
} }
@@ -53,7 +53,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Values[0].Value.ShouldBe(3.14); sink.Values[0].Value.ShouldBe(3.14);
sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good); sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good);
sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain); sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary> /// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary>
@@ -73,7 +73,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Alarms[0].State.Active.ShouldBeTrue(); sink.Alarms[0].State.Active.ShouldBeTrue();
sink.Alarms[0].State.Acknowledged.ShouldBeFalse(); sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
sink.Alarms[0].State.Severity.ShouldBe((ushort)700); sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary> /// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
@@ -93,7 +93,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
q.Quality.ShouldBe(OpcUaQuality.Bad); q.Quality.ShouldBe(OpcUaQuality.Bad);
q.Realm.ShouldBe(AddressSpaceRealm.Raw); q.Realm.ShouldBe(AddressSpaceRealm.Raw);
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test /// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
@@ -117,7 +117,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
} }
/// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the /// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes"));
sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false));
sink.ModelChanges.ShouldContain("EQ-1"); sink.ModelChanges.ShouldContain("EQ-1");
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
} }
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary> /// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
@@ -161,7 +161,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the very first computed ServiceLevel is always published even when it is /// <summary>Verifies that the very first computed ServiceLevel is always published even when it is
@@ -186,7 +186,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary> /// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary>
@@ -210,7 +210,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(snapshot); actor.Tell(snapshot);
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary> /// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary>
@@ -231,7 +231,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
actor.Tell(snapshot); actor.Tell(snapshot);
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }), AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader /// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader
@@ -256,7 +256,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary /// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary
@@ -282,7 +282,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 100 when the DB is unreachable /// <summary>Verifies that the calculator path computes 100 when the DB is unreachable
@@ -307,7 +307,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)100), AwaitAssert(() => publisher.Levels.ShouldContain((byte)100),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is /// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is
@@ -335,7 +335,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a detached local node publishes 0 (the calculator does not model /// <summary>Verifies that a detached local node publishes 0 (the calculator does not model
@@ -362,7 +362,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, },
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
// Now detach — expect the guard to drive ServiceLevel down to 0. // Now detach — expect the guard to drive ServiceLevel down to 0.
actor.Tell(new RedundancyStateChanged( actor.Tell(new RedundancyStateChanged(
@@ -374,7 +374,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that an actively-observed, recent peer probe of MY endpoint that came back /// <summary>Verifies that an actively-observed, recent peer probe of MY endpoint that came back
@@ -403,7 +403,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: a peer's NEGATIVE verdict about this node /// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: a peer's NEGATIVE verdict about this node
@@ -447,7 +447,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
// Healthy (250), NOT 0 — proves an aged negative verdict does not demote. // Healthy (250), NOT 0 — proves an aged negative verdict does not demote.
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults /// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults
@@ -474,7 +474,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c> /// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c>
@@ -503,7 +503,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that a peer probe result about a DIFFERENT node is ignored — it does not /// <summary>Verifies that a peer probe result about a DIFFERENT node is ignored — it does not
@@ -531,7 +531,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler /// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler
@@ -553,7 +553,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/> /// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/>
@@ -607,7 +607,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary> /// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
@@ -629,7 +629,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness /// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
@@ -654,7 +654,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less /// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less
@@ -677,7 +677,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, },
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
actor.Tell(new RedundancyStateChanged( actor.Tell(new RedundancyStateChanged(
Nodes: new[] Nodes: new[]
@@ -688,7 +688,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default /// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
@@ -714,7 +714,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
CorrelationId.NewId())); CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500)); duration: PresenceBudget);
} }
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/> /// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
@@ -28,7 +28,23 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// </summary> /// </summary>
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
{ {
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); /// <summary>
/// Upper bound for the <b>presence</b> waits in this class (<c>ExpectMsg</c> / <c>FishForMessage</c>).
/// </summary>
/// <remarks>
/// <para>Sized for a <b>real Roslyn compilation</b>, not for message passing.
/// <c>ApplyScriptedAlarms</c> drives <c>ScriptedAlarmEngine.LoadAsync</c>, which compiles every
/// alarm predicate through <c>ScriptEvaluator.Compile</c>; the <c>RegisterInterest</c> these tests
/// wait on is only sent once that finishes. A cold C# script compile is slow and highly variable —
/// far more so than anything else in this assembly — and Roslyn's caches are per-process, so the
/// first class to compile pays the worst of it. Under a fully parallel assembly run, sharing 14
/// cores with 40-odd other Akka test classes, 8 s was occasionally missed (Gitea #500).</para>
/// <para>Raising it is free on the happy path: these waits return the instant the message arrives,
/// so the value is an upper bound before giving up, not a delay, and it cannot make a genuinely
/// failing expectation pass. The <b>absence</b> assertions in this class deliberately keep their own
/// short literals — there the elapsed time is the point.</para>
/// </remarks>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
/// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary> /// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
private static EquipmentScriptedAlarmPlan Plan( private static EquipmentScriptedAlarmPlan Plan(
@@ -70,20 +86,22 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
Retain: false, Retain: false,
Enabled: true); Enabled: true);
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream) private static ScriptedAlarmEngine BuildEngine(
DependencyMuxTagUpstreamSource upstream, IAlarmStateStore? store = null)
{ {
var logger = new LoggerConfiguration().CreateLogger(); var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger); return new ScriptedAlarmEngine(
upstream, store ?? new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
} }
/// <summary>The local node id used by the redundancy-gating tests.</summary> /// <summary>The local node id used by the redundancy-gating tests.</summary>
private static readonly NodeId LocalNode = new("node-A"); private static readonly NodeId LocalNode = new("node-A");
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn( private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
TestProbe publish, TestProbe mux, NodeId? localNode = null) TestProbe publish, TestProbe mux, NodeId? localNode = null, IAlarmStateStore? store = null)
{ {
var upstream = new DependencyMuxTagUpstreamSource(); var upstream = new DependencyMuxTagUpstreamSource();
var engine = BuildEngine(upstream); var engine = BuildEngine(upstream, store);
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine, localNode)); var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine, localNode));
return (host, upstream); return (host, upstream);
} }
@@ -778,4 +796,162 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
evt.AlarmId.ShouldBe("alm-1"); evt.AlarmId.ShouldBe("alm-1");
evt.TransitionKind.ShouldBe("Activated"); evt.TransitionKind.ShouldBe("Activated");
} }
// ---------------------------------------------------------------------------------------------
// #487 — post-(re)load condition-node re-assert.
//
// A deploy that triggers a FULL address-space rebuild re-materialises every condition node fresh
// (inactive/acked/confirmed/unshelved). The engine reloads its persisted state and re-derives Active
// from the predicate, so it ends up CORRECT — but a still-active alarm has no transition to report,
// yielding EmissionKind.None, which OnEngineEmission drops. Without the re-assert the node reads
// NORMAL to every OPC UA client until the alarm's next real transition — which, for an alarm with
// static dependencies, may never come.
// ---------------------------------------------------------------------------------------------
/// <summary>A persisted condition state as it survives a restart / redeploy, seeded into the store the
/// host's engine loads from.</summary>
private static AlarmConditionState Persisted(
string alarmId = "alm-1",
AlarmEnabledState enabled = AlarmEnabledState.Enabled,
AlarmActiveState active = AlarmActiveState.Active,
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
AlarmConfirmedState confirmed = AlarmConfirmedState.Confirmed,
ShelvingKind shelving = ShelvingKind.Unshelved,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
enabled,
active,
acked,
confirmed,
shelving == ShelvingKind.Unshelved ? ShelvingState.Unshelved : new ShelvingState(shelving, null),
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: active == AlarmActiveState.Active ? lastTransitionUtc ?? DateTime.UtcNow : null,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
/// <summary>Seed <paramref name="state"/> into a fresh store and hand it back for
/// <see cref="Spawn"/>. The engine reads it during LoadAsync, exactly as it would after a restart.</summary>
private static IAlarmStateStore StoreWith(AlarmConditionState state)
{
var store = new InMemoryAlarmStateStore();
store.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult();
return store;
}
/// <summary>#487, the regression: an alarm whose persisted state is ACTIVE emits nothing on reload
/// (no transition to report), but the host must still write its state onto the freshly materialised
/// condition node — otherwise an active alarm silently reads normal after a full-rebuild deploy.</summary>
[Fact]
public void Reload_reasserts_an_active_alarm_onto_its_condition_node()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.Realm.ShouldBe(AddressSpaceRealm.Uns);
state.State.Active.ShouldBeTrue("the persisted state was Active — the rebuilt node must say so");
state.State.Acknowledged.ShouldBeFalse();
state.State.Enabled.ShouldBeTrue();
// The projection carries the definition's severity + resolved message, so a re-asserted node is
// indistinguishable from one the alarm's own transition would have written.
state.State.Severity.ShouldBe((ushort)1000); // 800 → Critical bucket → 1000
state.State.Message.ShouldBe("condition");
}
/// <summary>The re-assert is node-ONLY: it must never reach the <c>alerts</c> topic. That topic is the
/// historization path, so an alerts row per alarm per deploy would append a duplicate historian /
/// AVEVA record every time anyone deploys.</summary>
[Fact]
public void Reassert_never_publishes_to_the_alerts_topic()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
// Wait for the re-assert to actually happen FIRST — otherwise the absence window below could
// elapse before the load even completed and would prove nothing.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
// Absence assertion: the elapsed time IS the assertion, so this stays deliberately short.
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>An alarm sitting in the Part 9 no-event position already matches what the materialise
/// built, so it is NOT re-asserted — a deploy where nothing is in alarm writes no condition nodes at
/// all, and a never-fired alarm keeps the display name the materialise seeded as its Message.</summary>
[Fact]
public void A_normal_alarm_is_not_reasserted()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux); // empty store ⇒ Fresh ⇒ no-event position
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
// Sequence the absence: the re-assert (if any) happens right after the mux registration, so
// waiting for that first makes the window below cover the moment the write would have landed.
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Not just Active: every field a rebuild would silently lose is restored. A shelved (but
/// inactive) alarm is re-asserted too — a rebuilt node would otherwise come back unshelved, so an
/// operator's suppression would evaporate on the next deploy.</summary>
[Fact]
public void Reload_reasserts_a_shelved_but_inactive_alarm()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(
active: AlarmActiveState.Inactive,
acked: AlarmAckedState.Acknowledged,
shelving: ShelvingKind.OneShot)));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.State.Active.ShouldBeFalse();
state.State.Shelving.ShouldBe(AlarmShelvingKind.OneShot);
}
/// <summary>The re-asserted timestamp is when the state actually came about, NOT the deploy instant —
/// a client reading Time/ReceiveTime after a rebuild should still see when the alarm went active.</summary>
[Fact]
public void Reassert_carries_the_conditions_own_transition_timestamp()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var wentActiveAt = DateTime.UtcNow.AddHours(-3);
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(lastTransitionUtc: wentActiveAt)));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout)
.TimestampUtc.ShouldBe(wentActiveAt);
}
/// <summary>A disabled PLAN exposes no condition node at all (MaterialiseScriptedAlarms skips it) and
/// is not loaded into the engine — so it must not be re-asserted either, or the write would target a
/// node that does not exist.</summary>
[Fact]
public void A_disabled_plan_is_not_reasserted()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(enabled: false) }));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
} }
@@ -79,7 +79,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
entry.Message.ShouldContain("syntax error"); entry.Message.ShouldContain("syntax error");
entry.ScriptId.ShouldBe("script-7"); entry.ScriptId.ShouldBe("script-7");
entry.VirtualTagId.ShouldBe("vt-1"); entry.VirtualTagId.ShouldBe("vt-1");
}, duration: TimeSpan.FromMilliseconds(500)); }, duration: PresenceBudget);
// 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the // 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the
// inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this // inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this
@@ -260,7 +260,15 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
// The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned // The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned
// (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between // (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between
// the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either. // the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either.
//
// Because the order is genuinely undefined, the SENDER of the RegisterInterest must be captured
// as that message is received. Reading mux.LastSender after the loop instead would read the
// sender of whichever message happened to arrive SECOND — the dying child when the interleaving
// is [Register, Unregister] — so the identity assertion below silently depended on the very
// ordering this loop exists to tolerate. That made it fail roughly 10% of the time under a
// fully parallel assembly run, and never in isolation.
DependencyMuxActor.RegisterInterest? reg2 = null; DependencyMuxActor.RegisterInterest? reg2 = null;
IActorRef? secondChild = null;
var sawUnregister = false; var sawUnregister = false;
for (var i = 0; i < 2; i++) for (var i = 0; i < 2; i++)
{ {
@@ -269,6 +277,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
{ {
case DependencyMuxActor.RegisterInterest r: case DependencyMuxActor.RegisterInterest r:
reg2 = r; reg2 = r;
secondChild = mux.LastSender; // sender OF THIS message, not of the last one seen
break; break;
case DependencyMuxActor.UnregisterInterest: case DependencyMuxActor.UnregisterInterest:
sawUnregister = true; sawUnregister = true;
@@ -282,7 +291,8 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
reg2.TagRefs.ShouldNotContain("A"); reg2.TagRefs.ShouldNotContain("A");
// The replacement is a different actor ref than the original (auto-named, so no collision). // The replacement is a different actor ref than the original (auto-named, so no collision).
mux.LastSender.ShouldNotBe(firstChild); secondChild.ShouldNotBeNull();
secondChild.ShouldNotBe(firstChild);
} }
/// <summary> /// <summary>