Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14)

This commit is contained in:
Joseph Doherty
2026-08-20 11:49:06 -04:00
46 changed files with 4340 additions and 129 deletions
+26 -1
View File
@@ -297,7 +297,32 @@ non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. T
system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped
reading.
See `docs/DriverLifecycle.md` §"The comms-loss watchdog".
- **What comms loss now does downstream — one contract, six seams (archreview R3-02).** "A value consumer
never reports a quality better than its source's" is stated once in `docs/DriverLifecycle.md` §"The
quality contract" and implemented by `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`). Three
things changed with it, all client-visible:
- ⚠️ **Value nodes of a comms-lost driver now go Bad.** `DriverHostActor.FanCommsLossToValueNodes`
stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained**,
via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host holds no value
cache, so only the node can keep the value). #481 had wired comms loss to the mux and to Part 9
conditions and stopped one seam short of the external data plane. A consumer that treats Bad as "drop
the tag" (ScadaBridge) will see more Bads — that is the fix working.
- **Nothing Good is written on reconnect** — recovery is the driver's own re-publish (the poll engine's
`forceRaise`, MQTT's new initial push). Instead the host **audits**: refs still latched 30 s after a
reconnect log a Warning and increment `otopcua.driver.comms_latched_refs`. Non-zero on that counter
means a driver reconnects without re-publishing.
- **Every dedup on the recovery path is keyed on (value, status), not value.** A device that comes back
usually comes back holding the same reading, so a value-only dedup swallows the recovery and the node
stays Bad forever — the v3-gate trap, now closed in the Calculation driver, the virtual-tag actor and
(via a connection-loss stale) MQTT's plain-mode suppression. **Any new consumer of driver values must
repeat this**; a regression test for it must replay the IDENTICAL value or it proves nothing.
- Consequences per seam: MQTT/Sparkplug stale on connection loss and push cached snapshots on every
(re)subscribe (`docs/drivers/Mqtt.md`); the Calculation driver and virtual tags **hold** while any
input is Bad and inherit the worst input's sub-code, evaluate through Uncertain, and hand scripts the
input's real `StatusCode`/`SourceTimestampUtc` (`docs/drivers/Calculation.md`, `docs/ScriptEditor.md`)
— so a script that relied on receiving `null` for a dead input no longer runs at all while it is Bad.
See `docs/DriverLifecycle.md` §"The comms-loss watchdog" and §"The quality contract".
## mxaccessgw
@@ -580,3 +580,331 @@ T17 anytime; T18 after all; T19 last
```
Overall effort: **~2 dev-days code+unit, plus the rig session**. The three HIGH-RISK data-plane slices (T3 MQTT stale, T8 value-node Bad, T13/T16 hold-on-Bad) each land behind their full suites; the two recovery contract tests (T10, T16's identical-value case) must each be seen to FAIL once against a reverted dedup before merge — that is the whole reason this family re-appeared.
---
## Execution notes
Branch `fix/r3-02-stale-good-family` (worktree `.claude/worktrees/agent-a696a39981023fa0b`), off master
`72329eab`. Executed in plan block order T1, B (T2–T5), C (T6–T10), D (T12–T14), E (T15–T16), T17, T18.
**T11 and T19 are deliberately NOT in this branch** — T11 waits for the sibling R3-03 branch to merge
(it is the only edit that would touch `DriverInstanceActor` lifecycle alongside R3-03's restart work),
and T19 is the operator rig gate.
Red-before observations are recorded per task below: what was run, and the failure text that proves the
test could only pass once the production change landed.
### T1 — `InputQuality.WorstOf`
- RED: `dotnet test tests/Core/…Core.Abstractions.Tests --filter "FullyQualifiedName~InputQualityTests"`
⇒ **build failure**, 8 × `error CS0103: The name 'InputQuality' does not exist in the current context`.
- GREEN: 13 passed. `Core.ScriptedAlarms.Tests` 94/94 green with `WorstInputStatus` re-pointed (the
never-pushed/null skips stay in the alarm engine — they are alarm policy, not the shared rule).
### T2 — MQTT plain-mode initial push on `SubscribeAsync`
- RED: `--filter "FullyQualifiedName~MqttSubscriptionManagerRecoveryTests"` ⇒ **4 failed**, each on
`raised.Count` (0 observed, 1 expected) / `raised.Select(r => r.Reference)` — `SubscribeAsync` raised
nothing at all for an already-observed ref.
- GREEN: 77 `MqttSubscriptionManager*` tests pass, incl. the suppression control (an identical message
after the push is still swallowed). Full `Driver.Mqtt.Tests`: 790/790.
### T3 — `MqttConnection.ConnectionLost` + plain-mode stale
- RED: `--filter "…MqttSubscriptionManagerRecoveryTests|…ConnectionLost"` ⇒ **build failure**,
`CS1061: 'MqttSubscriptionManager' does not contain a definition for 'OnConnectionLost'` (×5) and
`CS1061: 'MqttConnection' does not contain a definition for 'ConnectionLost'` (×3).
- GREEN: 11/11 (8 manager recovery + 3 connection edge). Full `Driver.Mqtt.Tests`: 797/797.
- The event fires inside the existing `State == Connected` guard in `OnDisconnectedAsync`, which is
what makes "not on an initial-connect failure" and "not on dispose" true by construction rather
than by a second condition — `DisposeAsync` sets `Disposed` before tearing the client down.
### T4 — Sparkplug ingestor: initial push + connection-loss stale
- RED: `--filter "…SparkplugIngestorRecoveryTests"` ⇒ **build failure**, `CS1061: 'SparkplugIngestor'
does not contain a definition for 'OnConnectionLost'` (×2).
- GREEN: 6/6. Full `Driver.Mqtt.Tests`: 803/803.
- ⚠️ **Deviation from the plan's test rationale (recorded, not silently accepted):** the plan asks for
the identical-DBIRTH-replay assertion as this seam's vacuity guard. It is present, but **it is not a
falsifier here** — Sparkplug ingestion has no unchanged-value suppression at all (births/data
publish directly, never through `Deliver`), so that replay was already raised Good before the
change. That is now asserted explicitly by an added control,
`An_identical_birth_replay_is_raised_even_without_a_preceding_stale`, and the suite header says so.
The load-bearing assertion on this seam is the **stale** itself.
- The stale reports `BadNoCommunication` while a DDEATH keeps `BadCommunicationError`: the death is the
source announcing its own offline state, the stale is our transport losing the broker (and matches
what the host's S15 fan-out writes). The existing const's "no driver emits BadNoCommunication at all"
remark was corrected rather than left to rot.
### T5 — MQTT driver-level end-to-end recovery + docs
- The test is green on first run by construction (T2–T4 already landed), so the plan's TDD bar was met
the other way: **the wiring was deliberately broken and the test seen to fail.** Deleting
`connection.ConnectionLost += OnConnectionLost;` from `MqttSubscriptionManager.AttachTo` ⇒
`ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers` **FAILED** with
`Timed out waiting for the broker drop to publish a BadNoCommunication snapshot` (15 s). Restored,
green. That is the point of this test: the manager-level suites drive `OnConnectionLost()` directly
and stay green against an unforwarded seam.
- The outage edge is a real socket drop through MiniBroker → MQTTnet `DisconnectedAsync`; ingest is fed
through `Subscriptions.HandleMessage` because MiniBroker answers CONNECT/PINGREQ only and cannot
PUBLISH.
- Docs: `docs/drivers/Mqtt.md` §"Quality on connection loss and re-subscribe" (links the contract in
`DriverLifecycle.md`, records the two distinct "not communicating" codes and the operator-visible
behaviour change).
### T6 — `IOpcUaAddressSpaceSink.WriteStatuses` (interface default + node manager + both sinks)
- RED (the guard, first): adding the member to the interface and running
`--filter "…DeferredSinkForwardingReflectionTests"` ⇒ **`Every_forwarding_interface_method_reaches_the_inner_sink`
FAILED**. Note the failure surfaced as `System.ArgumentNullException : Value cannot be null. (Parameter
'statuses')` rather than the guard's "did NOT forward" message — the reflection harness passes `null`
for an `IReadOnlyList<>` parameter, so the *interface default* ran on the wrapper and threw. Red for
the right reason (the member was not forwarded); green once `DeferredAddressSpaceSink.WriteStatuses`
forwards. This is the memory'd "Deferred sink doesn't forward the new member" trap, and here the
default is not merely slower — it writes a **null value**, erasing the very value the annotation exists
to keep.
- RED (node manager): `--filter "…OtOpcUaNodeManagerWriteStatusesTests"` ⇒ **build failure**, `CS1061:
'OtOpcUaNodeManager' does not contain a definition for 'WriteStatuses'` (×5).
- GREEN: 5/5 new; full `OpcUaServer.Tests` 495 passed / 1 skipped; `Commons.Tests` 348/348.
- An unmaterialised NodeId is **skipped and not counted as a failure** (Debug tally via the SDK static
trace — the manager has no `ILogger`): the caller's per-driver index racing a surgical remove is an
expected shape, not a fault.
### T7 — `OpcUaPublishActor.AttributeStatusBatchUpdate`
- RED: `--filter "…OpcUaPublishActorStatusBatchTests"` ⇒ **build failure**, `CS0426: The type name
'AttributeStatusBatchUpdate' does not exist in the type 'OpcUaPublishActor'` (×3).
- GREEN: 58 `OpcUaPublishActor*` tests pass (3 new).
- Metered `OpcUaSinkWrite kind:"status"` so a status annotation is separable from a value write on the
same counter; the recording fake asserts **zero** value writes, since a handler falling through to the
value path would null every node.
### T8 — `DriverHostActor.FanCommsLossToValueNodes`
- RED: `--filter "…DriverHostActorCommsLossValueNodesTests"` ⇒ **2 failed**, both
`Timeout 00:00:15 while waiting for a message of type …AttributeStatusBatchUpdate` (the disconnect
produced no value-node write at all — the finding, reproduced).
- GREEN: 10 passed across the new suite + `DriverHostActorCommsLossTests` (mux half) +
`DriverHostActorBatchFanOutTests`, all unchanged.
- **Deviation (sequencing, not design):** the plan's snippet calls `TrackCommsLatchSet(...)` from inside
this method, but that helper is T9's — calling it here would not compile. The call is added in T9,
where the latch and its audit timer land together. R3-03 boundary respected: this is a new method plus
one branch in `OnDriverConnectivityChanged`; `HandleRestartDriver` and the watchdog are untouched.
### T9 — recovery audit (`_commsLatched` + timer + `otopcua.driver.comms_latched_refs`)
- RED: `--filter "…DriverHostActorCommsLossValueNodesTests"` ⇒ **build failure**, `CS1739: The best
overload for 'Props' does not have a parameter named 'commsRecoveryAuditDelay'`.
- GREEN: 6/6 in the suite; 20 passed across it + `DriverHostActorCommsLossTests` +
`DriverHostActorBatchFanOutTests` + `DriverHostActorPrimaryGateTests`.
- **Falsification of the control** (the plan's discipline, applied to the audit rather than to a dedup
key): deleting the prune loop in `ForwardBatchToMux` ⇒
`A_driver_that_republishes_every_ref_trips_no_audit` **FAILED** with `Received 1 message too many.
Expected 0 messages but received 1 that matched filter [Warning when Message contains "still
BadNoCommunication"]`. Restored, green. So the "no audit fires" assertion is falsifiable and the audit
is not merely dormant.
- Meter assertions filter on a per-test **driver tag**, not on the instrument name alone (#503: a
`MeterListener` sees every measurement in the process).
- The latch counts **RawPaths**, so the raw node and its UNS mirrors report as one ref — the operator is
told how many tags did not come back, not how many nodes.
### T10 — S15 end-to-end recovery contract + silent-resubscribe control
- RED: `--filter "…DriverHostCommsLossRecoveryTests"` ⇒ **2 failed**, `Timeout 00:00:15 while waiting for
a message of type …AttributeValueBatchUpdate` (before `AttributeStatusBatchUpdate` even existed in the
first draft, and again while the fake's instance id disagreed — see below).
- GREEN: 2/2 (33 s wall clock — see the reconnect-interval note). `Runtime.Tests` `~Drivers` filter:
333 passed / 3 skipped.
- **Two harness facts worth keeping** (both cost a red run to find):
- `DriverInstanceActor` takes its instance id from **`driver.DriverInstanceId`**, not from the spec the
host spawned it with. A fake returning a different id publishes under a key the host's ref map does
not contain, and every value is silently dropped as unmapped. The fake now takes the id.
- The fake's push-on-subscribe fires on the FIRST subscribe too, so the pre-outage value must come from
that push rather than from a separate seeding call — otherwise the probe holds an extra batch.
- **Deviation:** the plan sketched driving the outage with the #527 watchdog (`commsLossTimeout` 200 ms).
The host spawns its children with the **default** `reconnectInterval`/`healthPollInterval` and exposes
no seam for either, so the outage is driven by the production `ReconnectDriver` (operator Force
Reconnect) command instead — same `Reconnecting` entry, same `ConnectivityChanged(false)`, and it does
not touch `HandleRestartDriver` (R3-03's). The watchdog's own trip condition stays covered by
`DriverInstanceActorCommsLossWatchdogTests`. The 10 s `DefaultReconnectInterval` is why these two tests
take ~15 s each.
- The identical-value rule is honoured: the driver pushes **5.0 Good** before and after the outage, so the
recovery assertion cannot be satisfied by a value change. The control (`InitialPush = null`) asserts
`ExpectNoMsg` — no Good ever arrives — and that the audit fires with exactly 1 latched ref.
### T12 — `DependencyConsumerMuxAdapter` forwards the real status code
- RED: `--filter "…DependencyConsumerMuxAdapterTests"` ⇒ **2 failed**, both
`Shouldly.ShouldAssertException : consumer.Received[…].StatusCode should be 2150694912u` (0x80310000)
— the consumer received `0` (Good) for a `BadNoCommunication` input, on both the single and the batch
path. That is the finding, reproduced at the seam.
- GREEN: 11 passed (`DependencyConsumerMuxAdapterTests` + `DependencyMuxActorTests`).
- The false "quality is stripped at the mux boundary" comment is deleted and replaced with why it was
false (theme 6: the contract moved at #478/#519 and the comment outlived the fact).
### T13 — `CalculationDriver` input quality (hold on Bad, propagate worst, status-keyed dedup)
- RED: `--filter "…CalculationDriverInputQualityTests"` ⇒ **5 failed**: `Snapshot.StatusCode should be
2150694912u` (BadNoCommunication) on the Bad-input, cold-start and passthrough cases; `should be
1073741824u` (Uncertain) on the Uncertain case; `published.Count should be 1` on the sub-code-change
case. That is the laundering, reproduced from three directions.
- GREEN: whole `Driver.Calculation.Tests` project 61/61 — the 9 new plus every pre-existing case
unchanged (the Good-only paths are byte-identical).
- **Vacuity check performed** (the plan's requirement): reverting the dedup key to value-only
(`if (st.HasValue && Equals(st.LastValue, value)) return null;`) ⇒
`An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with
`published.Count should be 1` — i.e. the recovery was swallowed, exactly the "Bad forever" trap.
Restored, green.
- `TagState.LastPublishedBad` (two-state) became `HasPublished` + `LastPublishedStatus` (uint), which is
what lets a Bad→different-Bad transition republish and what makes `ReadAsync` agree with the
subscriber. The evaluator is still handed bare values here — T14 hands it the snapshots.
### T14 — `CalculationEvaluator` snapshot overload
- RED: `--filter "…CalculationEvaluatorTests"` ⇒ **build failure**, `CS1503: cannot convert from
'Dictionary<string, DataValueSnapshot>' to 'IReadOnlyDictionary<string, object?>'` (×3) — no snapshot
overload existed.
- GREEN: `Driver.Calculation.Tests` 64/64.
- `BuildReadCache` is **deleted**, not fixed: the snapshots now pass straight through as the read cache.
The value-only overload survives (tests use it) but is now the one that fabricates Good, and its
xmldoc says so and points at the snapshot overload.
- Docs: `docs/drivers/Calculation.md` gains an "Input quality" section (the policy table + the
behaviour change for null-tolerant scripts) and the dedup bullet now says **(value, status)**.
### T15 — `IVirtualTagEvaluator` snapshot overload + Roslyn read-cache pass-through
- RED: `--filter "…RoslynVirtualTagEvaluatorTests"` ⇒ **build failure**, `CS1503: cannot convert from
'Dictionary<string, DataValueSnapshot>' to 'IReadOnlyDictionary<string, object?>'` (×3).
- GREEN: 24/24 in that suite; full solution build clean (0 errors), so **all five existing
`IVirtualTagEvaluator` fakes compile untouched** — which is the point of default-implementing the
overload. A test pins that fallback explicitly (`ValueOnlyEvaluator`).
- `RoslynVirtualTagEvaluator.BuildReadCache` is **deleted**; the snapshots are the read cache. The
value-only overload now projects up into the snapshot one, so there is a single evaluation body.
### T16 — `VirtualTagActor` honours input quality
- RED: `--filter "…VirtualTagActorInputQualityTests"` ⇒ **7 failed**, incl. `bad.StatusCode should be
2150694912u`, `result.StatusCode should be 1073741824u` (Uncertain), `reassert.StatusCode should be
2150694912u`, and `Expected no messages … instead we received EvaluationResult { … StatusCode = 0 }`
for the cold-start case — i.e. a Bad input produced a fresh Good result.
- GREEN: 58 passed across `VirtualTag*` (the 7 new + the R2-03 transition suites + the host bridge's
new historize-parity case), all pre-existing cases unchanged.
- **Vacuity check performed** (the plan requires this one explicitly): reverting the dedup key to
value-only ⇒ `An_identical_value_is_republished_when_recovering_from_a_bad_input` **FAILED** with
`Timeout 00:00:15 while waiting for a message of type …EvaluationResult` — the recovery was swallowed
entirely. Restored, green.
- ⚠️ **Deviation from the plan's design, deliberate and load-bearing.** The plan says the worst-of-input
walk should treat a **never-pushed** dependency as `BadWaitingForInitialData` and hold. Implemented
literally, that HOLDS evaluation during ordinary warm-up — but this actor (unlike the Calculation
driver) has always evaluated on partial arrival and published the partial result, and two existing
tests assert exactly that (`VirtualTagActorTests.Evaluator_result_flows_to_parent_as_EvaluationResult`
and `DependencyMuxActorTests.VirtualTagActor_PreStart_registers_deps_…_end_to_end`, which both went red
under the literal reading). The worst-of walk therefore covers **arrived** deps only — the same skip
`ScriptedAlarmEngine.WorstInputStatus` has always made — and cold start stays governed by R2-03's
existing `AllDependenciesArrived` gate on the publish tails. The plan's own risk note ("every Good-path
assertion in the existing suites stays byte-identical") is what this preserves.
- `_lastPublishedBad` became `_hasPublished` + `_lastPublishedStatus`, which is also what makes
`ReassertValue` re-emit `BadNoCommunication` after a deploy instead of guessing Good.
- Docs: `docs/ScriptEditor.md` §"Input quality: what a script sees, and when it runs at all".
### T17 — Docs: the contract stated once
- `docs/DriverLifecycle.md` gains §"The quality contract" (the contract quoted verbatim + a table of the
six seams that implement it + the consumer-visible warning) and a new sub-section under the comms-loss
fan-out covering the value-node fan, `WriteStatuses`, and the recovery audit.
- Pointers added, each linking back rather than restating: `docs/Raw.md`, `docs/Uns.md`,
`docs/ScriptEditor.md` (T16), `docs/drivers/Calculation.md` (T14), `docs/drivers/Mqtt.md` (T5).
- `CLAUDE.md`: one bullet under the comms-loss watchdog section — value nodes go Bad, nothing Good on
reconnect (+ the audit meter), and the **(value, status)** dedup rule any future consumer must repeat.
### Merge of master (R3-03 + R3-05 + R3-01) — before T11
`git merge master` at `d79d51dd`. Two conflicts, both **additive-vs-additive** on parameters R3-03 and
this plan each appended last:
- `DriverHostActor` — `reprovisionBackoff` (R3-03) and `commsRecoveryAuditDelay` (T9) both claimed the
final ctor/Props slot, plus adjacent field blocks. Resolved by keeping BOTH in master-then-mine order
across all three positional sites (Props signature, `Props.Create` call, ctor), per the file's own
"new parameters go LAST in all three places" warning.
- `V3ArtifactSeeder` — R3-03 had independently added the same `enabled` + `driverFactory` seams T10
needed (`driversEnabled`). **Master's naming won**; mine was deleted and the T10 call site moved to
`enabled: true` + named `commsRecoveryAuditDelay:`. `Spawn`/`SpawnHostAndApply` carry all three
optional knobs.
- One non-conflicting mis-merge caught by eye: git interleaved the new
`DriverCommsLatchedRefs` counter *between* `PrimaryGateDenied`'s xmldoc and its declaration in
`OtOpcUaTelemetry`. Re-ordered.
- Post-merge: solution builds clean, `Runtime.Tests` **670 passed / 3 skipped**.
### T11 — `DriverInstanceActor.PostStop` announces comms loss
- RED: `--filter "…Stopping_a_connected_child_announces…"` ⇒ **FAILED**, `Expected a message of type
…ConnectivityChanged, but received {<Terminated>…}` — stopping announced nothing at all.
- GREEN: `DriverInstanceActor*` 67/67; full `Runtime.Tests` **672 passed / 3 skipped**.
- **Deviation (narrowing, deliberate):** the Tell is **gated on the child having reached `Connected`**
(`_connectedSinceUtc != default`, the field R3-03 added for the watchdog grace) rather than
unconditional as the plan sketched. A never-connected child's nodes are still
`BadWaitingForInitialData`, which is more precise than "communication lost"; overwriting it would claim
an outage that never happened. Pinned by a control test
(`Stopping_a_never_connected_child_announces_nothing`).
- With R3-03 merged the recovery leg holds: `PostStop` → host latches Bad → R3-03's `SpawnChild` always
sends the desired set → the fresh child subscribes → the driver's initial push clears the latch.
### T18 — full-suite sweep + bookkeeping
- **Full-solution unit sweep green** (`dotnet test ZB.MOM.WW.OtOpcUa.slnx`): every project `Failed: 0`.
Notable per-project counts after this branch: `Commons.Tests` 348, `OpcUaServer.Tests` 498/1 skipped,
`Driver.Mqtt.Tests` 803, `Driver.Calculation.Tests` 64, `Core.ScriptedAlarms.Tests` 94,
`Core.Abstractions.Tests` (incl. the new `InputQualityTests`) green, `Runtime.Tests` 672/3 skipped.
Docker-gated integration suites skip cleanly as usual (Modbus 34 skipped, S7 3, AbCip 3, Galaxy 8,
Historian.Gateway 6).
- **`Runtime.Tests` × 5** for the #500 flake profile: **four runs 672 passed / 3 skipped**; one run
reported **1 failure** whose test name was lost to an output filter on that invocation. Not reproduced
in four subsequent full runs, and every suite this plan touched was run green individually many times.
Recorded rather than hidden — if it recurs, capture it with
`dotnet test … --logger "console;verbosity=normal"` and check it against the #500 list.
- Bookkeeping: `archreview/plans/STATUS.md` gains an R3-02 section (what landed per task, the
client-visible behaviour changes, the suite state, and the fact that **T19 the live gate is not run**).
- **Deviation:** the plan also asks for edits to "the four source reports' prior-finding tables". Those
tables are the round-3 review record written at `12b0035a`, and the sibling round-3 branches (R3-01,
R3-03, R3-04) likewise left them untouched, tracking progress in STATUS.md + the plan register instead.
Followed that precedent rather than diverging mid-round.
---
## Outstanding
- **T19 — the live gate (merge-blocking).** Not run: it needs the docker-dev rig plus the shared Modbus
fixture, and it is the only evidence that the identical-value recovery works end to end on a real
device. Recipe unchanged in §Live gate; the constant HR[5] vs auto-incrementing HR[100] pairing is what
makes it non-vacuous.
---
## Review outcome (verdict MERGE, two Low items applied)
Both were **real** — each is a place where a polled/observed surface disagreed with what the contract
says, i.e. the same class of bug this plan exists to close, one hop further out. Applied TDD, one commit.
1. **`CalculationDriver.ReadOne` guarded on `!st.HasValue`** — so a tag whose only publish was an
inputs-Bad report (no value of its own) read back `BadWaitingForInitialData` while its subscriber had
been told `BadNoCommunication`. The guard is now `!st.HasPublished`, which is the state that actually
means "nothing has ever been said about this tag".
- RED: new `ReadAsync_after_an_inputs_Bad_with_no_prior_value_reports_the_bad_status` ⇒
`Shouldly.ShouldAssertException : read[0].StatusCode should be 2150694912u`.
- The pre-existing `An_unevaluated_tag_still_reads_BadWaitingForInitialData` is the control for the
other side of the same guard and stayed green.
2. **`VirtualTagActor.OnInputsBad` metered `VirtualTagEval{outcome=inputs-bad}` above the
`AllDependenciesArrived()` return** — so ordinary cold start produced an "inputs bad" measurement per
trigger. The `Add` moved below **both** guards (readiness and transition-once), so the counter now
measures tags that actually report.
- RED: new `The_inputs_bad_counter_does_not_tick_during_warm_up` ⇒
`Shouldly.ShouldAssertException : meter.Count("inputs-bad") should be 0`.
- The absence half carries a **positive control in the same test** (completing the dep set ⇒ exactly
one measurement), so it cannot pass for want of a working `MeterListener`.
**Suites after the fixes:** `Driver.Calculation.Tests` **65/65**; `Runtime.Tests` `~VirtualTag`
**59/59**; `Core.ScriptedAlarms.Tests` **94/94**.
**STATUS.md corrected:** the anonymous Runtime.Tests flake is
`DriverHostActorNativeAlarmAckRoutingTests.RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal`
— untouched by this branch, known #500 profile, 3× green in isolation per the reviewer.
@@ -5,19 +5,19 @@
{
"id": "T1",
"subject": "InputQuality.WorstOf shared helper (Core.Abstractions) + truth-table tests; re-point ScriptedAlarmEngine.WorstInputStatus",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T2",
"subject": "MQTT plain-mode initial push of cached snapshots on SubscribeAsync — push-model forceRaise (05/STAB-18 shape b)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T3",
"subject": "MqttConnection.ConnectionLost event + plain-mode stale observed refs to BadNoCommunication (value retained) so the identical replay recovers (05/STAB-18 shape a)",
"status": "pending",
"status": "done",
"blockedBy": [
"T2"
]
@@ -25,7 +25,7 @@
{
"id": "T4",
"subject": "SparkplugIngestor: initial push on subscribe + connection-loss stale; rewrite the 'values are deliberately not staled' remark",
"status": "pending",
"status": "done",
"blockedBy": [
"T3"
]
@@ -33,7 +33,7 @@
{
"id": "T5",
"subject": "MQTT driver-level end-to-end recovery test (broker drop -> Bad -> identical retained replay -> Good) + docs/drivers/Mqtt.md section",
"status": "pending",
"status": "done",
"blockedBy": [
"T4"
]
@@ -41,13 +41,13 @@
{
"id": "T6",
"subject": "IOpcUaAddressSpaceSink.WriteStatuses (StatusWrite, default impl) + OtOpcUaNodeManager status-only batch retaining value + SdkAddressSpaceSink/DeferredAddressSpaceSink forwards (03/S15)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T7",
"subject": "OpcUaPublishActor.AttributeStatusBatchUpdate handler -> sink.WriteStatuses (metered, partial-failure Warning)",
"status": "pending",
"status": "done",
"blockedBy": [
"T6"
]
@@ -55,7 +55,7 @@
{
"id": "T8",
"subject": "DriverHostActor.FanCommsLossToValueNodes: one BadNoCommunication status batch over raw + UNS nodes on ConnectivityChanged(false); nothing on reconnect (new method — R3-03 boundary)",
"status": "pending",
"status": "done",
"blockedBy": [
"T7"
]
@@ -63,7 +63,7 @@
{
"id": "T9",
"subject": "Recovery audit: _commsLatched per driver, pruned by routed values, CommsRecoveryAudit timer + otopcua.driver.comms_latched_refs meter (dedup-trap tripwire)",
"status": "pending",
"status": "done",
"blockedBy": [
"T8"
]
@@ -71,7 +71,7 @@
{
"id": "T10",
"subject": "S15 end-to-end recovery contract test: real DriverInstanceActor + scripted fake with IDENTICAL-value initial push; silent-resubscribe falsification control",
"status": "pending",
"status": "done",
"blockedBy": [
"T9"
]
@@ -79,7 +79,7 @@
{
"id": "T11",
"subject": "DriverInstanceActor.PostStop Tells ConnectivityChanged(false) so operator restart / respawn latches nodes Bad (land after R3-03 merges)",
"status": "pending",
"status": "done",
"blockedBy": [
"T8"
]
@@ -87,13 +87,13 @@
{
"id": "T12",
"subject": "DependencyConsumerMuxAdapter forwards the real StatusCode; delete the false 'quality is stripped' comment (05/STAB-19)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T13",
"subject": "CalculationDriver input quality: snapshots per dep, hold on Bad, publish worst-of-input once per status change, status-keyed dedup bypass on recovery, ReadOne parity",
"status": "pending",
"status": "done",
"blockedBy": [
"T1",
"T12"
@@ -102,7 +102,7 @@
{
"id": "T14",
"subject": "CalculationEvaluator snapshot overload: read cache carries real input status + timestamps; passthrough fast path on snapshots",
"status": "pending",
"status": "done",
"blockedBy": [
"T13"
]
@@ -110,13 +110,13 @@
{
"id": "T15",
"subject": "IVirtualTagEvaluator snapshot overload (default-implemented) + RoslynVirtualTagEvaluator.BuildReadCache pass-through (02/S14)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T16",
"subject": "VirtualTagActor honours input quality: snapshot map, hold on Bad, worst-of status, (value,status) dedup key, reassert carries status; host-bridge historize parity test",
"status": "pending",
"status": "done",
"blockedBy": [
"T1",
"T15"
@@ -125,13 +125,13 @@
{
"id": "T17",
"subject": "Docs: the quality contract stated once (DriverLifecycle.md) + Raw/Uns/ScriptEditor/Calculation pointers + CLAUDE.md bullet",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
"id": "T18",
"subject": "Full-suite sweep (Runtime.Tests 3x for the #500 flake profile) + STATUS.md / source-report bookkeeping",
"status": "pending",
"status": "done",
"blockedBy": [
"T5",
"T10",
+38
View File
@@ -499,3 +499,41 @@ and the 12-item action list are in `00-OVERALL.md`.
**Round-3 plans: NOT yet written.** Next step is one plan per action-list item (R3-01…), house format,
as round 2 did.
---
## R3-02 — the stale-Good family (branch `fix/r3-02-stale-good-family`)
Plan: [`R3-02-stale-good-family-plan.md`](R3-02-stale-good-family-plan.md) (19 tasks; per-task red-before
evidence in that file's **Execution notes**). Closes OVERALL action item #2 — **03/S15**, **05/STAB-19**,
**05/STAB-18**, **02/S14** — by stating the quality contract once and pinning it per seam.
**Landed (T1–T18, one commit each; branched off `72329eab`, merged master `d79d51dd` mid-flight):**
| Task | What |
|---|---|
| T1 | `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`) — the one worst-of-input rule; `ScriptedAlarmEngine.WorstInputStatus` re-pointed at it (its never-pushed skip stays alarm-local) |
| T2–T5 | **MQTT (05/STAB-18)**: initial push of cached snapshots on every (re)subscribe (plain + Sparkplug); `MqttConnection.ConnectionLost` → stale observed refs to `BadNoCommunication`, value retained; end-to-end driver test through a real socket drop; `docs/drivers/Mqtt.md` |
| T6–T10 | **03/S15**: `IOpcUaAddressSpaceSink.WriteStatuses` (status-only, value retained) + node manager + both sinks; `OpcUaPublishActor.AttributeStatusBatchUpdate`; `DriverHostActor.FanCommsLossToValueNodes`; the recovery audit (`_commsLatched` + `otopcua.driver.comms_latched_refs`); the identical-value recovery contract test + silent-resubscribe control |
| T11 | `DriverInstanceActor.PostStop` announces comms loss (gated on having reached Connected), so an operator Restart / supervision respawn latches Bad — recovery rides R3-03's re-provision |
| T12–T14 | **05/STAB-19**: mux adapter forwards the real status code; Calculation holds on Bad, propagates the worst input status, status-keyed dedup; evaluator read cache carries real status + timestamps |
| T15–T16 | **02/S14**: `IVirtualTagEvaluator` snapshot overload (default-implemented) + Roslyn pass-through; `VirtualTagActor` holds on Bad, inherits the worst input status, `(value, status)` dedup, reassert carries the status |
| T17 | The contract stated once in `docs/DriverLifecycle.md` §"The quality contract"; pointers from `Raw.md`, `Uns.md`, `ScriptEditor.md`, `drivers/Calculation.md`, `drivers/Mqtt.md`; one `CLAUDE.md` bullet |
| T18 | Full unit sweep — every project green; `Runtime.Tests` run 5× (see flake note below) |
**Client-visible behaviour changes** (called out in the docs task): value nodes of a comms-lost driver
now go **Bad with the last value retained**; MQTT tags go Bad on a broker drop rather than at the 90 s
watchdog; calc tags and virtual tags **hold** while an input is Bad and report that input's sub-code, so
a script that relied on receiving `null` for a dead input no longer runs while it is Bad.
**Test-suite state at `62c5bc3a`:** full-solution unit sweep green. `Runtime.Tests` 672 passed / 3
skipped on four separate runs; one further run showed a single failure —
`DriverHostActorNativeAlarmAckRoutingTests.RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal`
(named by the reviewer, who saw it once and then 3× green in isolation). It is untouched by this branch
and matches the known #500 flake profile (~13 %/run); not reproduced in four subsequent full runs. No
pre-existing red on the base commit.
**NOT done here — T19, the live gate**, which is the merge-blocking rig session (docker-dev + the Modbus
`standard` fixture: constant HR[5] proves the force-republish, HR[100] is the positive control; plus the
deadband falsification leg, the operator-restart leg, and the MQTT retained-replay leg). Recipe in the
plan's §Live gate.
+64 -2
View File
@@ -306,6 +306,37 @@ So a driver whose `InitializeAsync` disposes anything on its failure path must b
`MTConnectDriver` does today, because it is the only one that tears down inside a `catch`. The
shutdown paths deliberately keep the single trailing write.
### The quality contract
> **A value consumer never reports a quality better than its source's.** A node derived from a driver
> value — the raw node, every UNS mirror, a Calculation output, a virtual tag, a scripted condition's
> quality — carries, at worst, the worst status among its inputs: a Bad input makes the derived node Bad
> **with that input's sub-code and the last-known value**; an Uncertain input makes it Uncertain; a source
> that has stopped communicating is a Bad input (`BadNoCommunication`) for everything downstream of it,
> **including the external OPC UA data plane**. Recovery is a *publish*, never an assumption: the first
> post-recovery value must land even when it equals the value held before the outage, so every dedup on
> the path — driver caches, the node's own status, a consumer's last-published memory — must be keyed on
> **(value, status)**, not value alone.
This is one rule with one implementation of its comparison,
`ZB.MOM.WW.OtOpcUa.Core.Abstractions.InputQuality` (`WorstOf` / `IsUsable`), used by every derived-value
seam. It was written down because the same lie had been fixed four times in four places and re-opened in
a fifth (archreview round 3, theme "the stale-Good family"). The seams that implement it:
| Seam | What it does | Where |
|---|---|---|
| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained** | `DriverHostActor.FanCommsLossToValueNodes` (below) |
| Driver comms loss → dependency mux | synthetic Bad per ref, so scripts and alarms see the source die | `DriverHostActor.FanCommsLossToDependencyMux` |
| Driver comms loss → Part 9 conditions | condition quality annotated out of band | `DriverHostActor.OnDriverConnectivityChanged` |
| MQTT / Sparkplug | connection loss stales observed refs; every (re)subscribe pushes the cached snapshot | [`docs/drivers/Mqtt.md`](drivers/Mqtt.md#quality-on-connection-loss-and-re-subscribe) |
| Calculation driver | holds on Bad, propagates the worst input status | [`docs/drivers/Calculation.md`](drivers/Calculation.md#input-quality) |
| Virtual tags | holds on Bad, inherits the worst input status, reassert carries it | [`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all) |
| Scripted alarms | worst-of-input quality annotated out of band; Bad inputs hold the condition | [`docs/ScriptedAlarms.md`](ScriptedAlarms.md) |
⚠️ **Consumer-visible:** value nodes of a comms-lost driver now go **Bad**, where they used to serve
their last value at Good. A consumer that treats a Bad value as "drop the tag" (ScadaBridge's data
connection layer, say) will see more Bads than before — that is the fix working, not a regression.
### The comms-loss watchdog (Gitea #527) — `GetHealth()` is now load-bearing
`GetHealth()` used to be **reporting-only**: `DriverInstanceActor.PublishHealthSnapshot` read it every 30 s
@@ -391,8 +422,8 @@ on it still gets its initialise attempt.
#### The fan-out that watchdog triggers: indexed per driver, and one batch
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
`BadNoCommunication` at every ref the flapping driver owns — the value fan to the dependency mux, and the
alarm fan to the publish actor's condition quality. Both used to answer "which refs are this driver's?"
`BadNoCommunication` at every ref the flapping driver owns — **three** fans: the value nodes (the
external OPC UA surface), the dependency mux, and the publish actor's condition quality. Both used to answer "which refs are this driver's?"
by **scanning the node's entire ref map and filtering** — the maps are keyed by the composite
`(DriverInstanceId, RawPath)`, so one driver's flap walked all ~50k keys on a large node to find its own
few thousand. Each now has a secondary index, `DriverInstanceId → [(RawPath, node set)]`
@@ -418,6 +449,37 @@ its last one — everything on the subscription path was already wrapped in `Att
`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays
for tests and direct senders.
#### The third fan: the value nodes (archreview 03/S15)
The two fans above reach *internal* consumers and *conditions*. Until archreview round 3 the driver's raw
value variables and their UNS mirrors — what an OPC UA client actually subscribes to — got nothing, so a
client saw the last value at **Good** quality for the whole outage. That was invisible on Modbus (which
degrades per tag within one poll interval on its own) and total on every driver whose failure shape is
"the reader stops producing": a coalesced-read throw, a dropped push session, or simply a driver whose
last act was a Good publish.
`FanCommsLossToValueNodes` closes it with **one status-only batch** — a new sink primitive,
`IOpcUaAddressSpaceSink.WriteStatuses` → `OtOpcUaNodeManager.WriteStatuses` — that changes each node's
StatusCode and Timestamp and **leaves its Value alone**. Status-only is not an optimisation: the host
holds no value cache (v3's single-source design deliberately removed the independent buffer), so only the
node itself can retain the last known value, and a Bad node still carrying its last reading is the OPC UA
Part 8 convention a SCADA consumer expects. Two details:
- It is **ungated by redundancy role**, like the condition annotation: a Secondary keeps its address space
honest for failover.
- An unmaterialised NodeId is **skipped, never created** — a status carries nothing to create a node from,
and the per-driver index can legitimately name a node a surgical remove has just dropped.
**Nothing is written on reconnect.** Publishing Good there would assert a quality the host has not
observed. Recovery is the driver's own re-publish: for a poll driver the watchdog's `DetachSubscription`
forces a fresh `PollGroupEngine` subscription whose initial poll runs `forceRaise: true`; for MQTT it is
the initial push added by 05/STAB-18. What the host does instead is **audit** it — `CommsRecoveryAudit`
fires `DefaultCommsRecoveryAuditDelay` (30 s) after the driver reconnects and reports any ref still
latched Bad, as a Warning naming the driver plus the `otopcua.driver.comms_latched_refs` counter (tagged
`driver`, `driver_type`). That counter going non-zero means a driver reconnected without re-publishing —
a push driver missing its initial push, or a value-only dedup swallowing an unchanged value. It is a
tripwire, not a repair: nothing downstream depends on it.
### The connect path feeds the circuit breaker (R2-10 T10 closure)
`IDriver.InitializeAsync` is routed through the capability invoker as
+11
View File
@@ -123,6 +123,17 @@ the full design. Highlights:
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
## Quality: a raw tag's node when its driver stops communicating
A raw tag's node — and every UNS mirror of it — goes **`BadNoCommunication` with its last value
retained** when the owning driver is declared comms-lost, and clears when the driver re-publishes. It is
never cleared by the server on reconnect: recovery is a value, never an assumption. The rule and the
seams that implement it are stated once in
[`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract).
Calc tags authored here inherit the same rule from their inputs — see
[`docs/drivers/Calculation.md` §"Input quality"](drivers/Calculation.md#input-quality).
## Retirement note
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
+23
View File
@@ -399,6 +399,29 @@ reference map identically, so their plans stay byte-parity.
---
## Input quality: what a script sees, and when it runs at all
The rule is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract), and it
applies identically to virtual-tag scripts and to `Calculation` driver scripts — they share
`VirtualTagContext` and this editor, so one policy keeps this page true for every `ctx.GetTag` call
site.
- **`ctx.GetTag(path).StatusCode` and `.SourceTimestampUtc` are real.** The read cache used to stamp
every dependency `Good` with an evaluation-time timestamp, so a quality-aware script could not be
written at all — it was told Good even for an input whose driver had stopped communicating.
- **A script is NOT run while any of its declared inputs is Bad.** The tag instead reports that
input's own status (`BadNoCommunication`, `BadWaitingForInitialData`, …) carrying its last known
value. So the `(double)ctx.GetTag("x").Value` idiom is safe against a dead input: the script simply
does not run, rather than throwing on every trigger and filling `/script-log`.
- **An Uncertain input IS passed to the script**, and the output inherits that Uncertain sub-code. An
Uncertain value is present and a script can make a qualified decision from it; that is the alarm
engine's long-standing rule, now shared.
- **The output never reports better than its worst input.** A Good result over an Uncertain input is
published Uncertain; nothing is published Good while an input is Bad.
- ⚠️ **If you wrote a script that deliberately handled `null` for a dead input, it no longer runs
while that input is Bad.** Assume your inputs are usable — the runtime has already decided they are.
## Known Limitations / Follow-ups
- **Tag-path completion shows resolvable keys only.** Surfacing the UNS browse
+11
View File
@@ -476,6 +476,17 @@ not — clients pick those up on their next browse. And a **degraded** non-rebui
(some node failed to materialise) deliberately does not record its composition as
applied, so the next deploy re-diffs and repairs it.
## Quality on a UNS node
A UNS reference variable mirrors its backing raw tag exactly — value, status and timestamps — so when
that tag's driver stops communicating the mirror goes **`BadNoCommunication` with its last value
retained**, in the same fan-out message as the raw node, and clears only when the driver re-publishes.
A VirtualTag on the same equipment follows the same rule from its own inputs: it holds while any input
is Bad and reports that input's sub-code. The contract, and every seam that implements it, is stated
once in [`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract);
what a script sees is in
[`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all).
## See also
- [Configuration.md](Configuration.md) — the underlying config entities.
+35 -2
View File
@@ -81,10 +81,43 @@ Two deploy-time gates run over the result:
- **Nothing publishes until every declared dependency has arrived at least once.** Before that a read
returns `BadWaitingForInitialData`. This is the same gate `VirtualTagActor` uses, and it exists so a
calculation cannot publish a confident-looking value derived from defaults.
- **Equal results are deduped** — an evaluation producing the same value as last time does not
re-publish.
- **Equal results are deduped on (value, *status*)** — an evaluation producing the same value *and*
the same status as last time does not re-publish. The status half is load-bearing: a device that
comes back after an outage usually comes back holding the reading it had before, so a value-only
dedup would swallow the recovery and leave the node Bad forever.
- **Timers are grouped by interval**, one timer per distinct interval, not one per tag.
## Input quality
The contract is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](../DriverLifecycle.md#the-quality-contract): a
value consumer never reports a quality better than its source's. A calc tag's inputs are other tags'
live values, so its output inherits their quality:
| Worst input status | What the driver does | What the calc tag publishes |
|---|---|---|
| Good | evaluates | the computed value, `Good` |
| Uncertain (any sub-code) | **evaluates** — an Uncertain value is present and usable | the computed value, stamped with that Uncertain sub-code |
| Bad (any sub-code) | **holds** — the script does not run | the **last known value**, stamped with that input's Bad sub-code, once per change of status |
| a dependency never pushed | holds (the pre-existing warm-up gate) | nothing — the node stays `BadWaitingForInitialData` |
Notes worth knowing before authoring against this:
- **The sub-code propagates**, so the output says *why*: `BadNoCommunication` means go look at the
device feeding the input; `BadInternalError` means this tag's own script failed.
- **Holding on Bad is what removes the NRE storm.** A comms-lost input used to arrive as *Good, null*,
so `(double)ctx.GetTag("x").Value` threw on every trigger and filled `/script-log` with identical
"script threw" entries. Now the script simply is not run, and **no script-log entry is written** — an
input dying is not a script fault.
- ⚠️ **A script that relied on receiving `null` for a dead input no longer runs at all** while that
input is Bad. Rewrite such a script to assume its inputs are usable; the driver has already decided
they are.
- **`ctx.GetTag(path).StatusCode` and `.SourceTimestampUtc` are real.** The read cache used to stamp
every input `Good` at evaluation time; it now passes the input's own snapshot through. Since Bad
inputs never reach a script, what a script can observe is Good or Uncertain.
- **`ReadAsync` reports the same status the subscription carried**, so a polled read and a subscribed
client never disagree about the same node.
## Error semantics
An evaluator failure publishes **Bad quality carrying the last-known value** — the value is not
+35
View File
@@ -415,6 +415,41 @@ one session, and carrying it across made the host's re-subscribe resolve an empt
SUBSCRIBE at all, and leave the driver Connected/Healthy and permanently deaf. Sparkplug's
`SparkplugIngestor.AttachTo` resets its own session state for the same reason.
## Quality on connection loss and re-subscribe
The quality contract this implements is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](../DriverLifecycle.md#the-quality-contract).
Two edge behaviours make MQTT — a push-model driver — honour it. A poll driver gets both free from
`PollGroupEngine` (a fresh subscription's first poll runs with `forceRaise: true`); MQTT had neither,
which is archreview 05/STAB-18.
- **Connection loss stales, keeping the value.** On the `Connected → Reconnecting` edge
`MqttConnection.ConnectionLost` fires, and both ingest paths mark every **already-observed**
reference `BadNoCommunication` with its **last value retained** and publish the change. A
never-observed reference (still `BadWaitingForInitialData`) and an already-Bad one are left alone.
The event does **not** fire for a failed *initial* connect or during dispose — neither is an outage.
- **Every (re)subscribe pushes the cached snapshots.** `SubscribeAsync` republishes each observed
reference's cached snapshot onto the new handle before any broker traffic. This is what refills a
node an address-space rebuild reseeded `BadWaitingForInitialData` on a quiet topic — a rebuild
triggers no retained replay and, in Sparkplug, no rebirth.
**Why the stale is what makes recovery work.** The plain path suppresses a message whose value *and*
status equal the driver-owned cache, and that cache survives reconnects and redeploys. So the retained
message a broker replays on reconnect — routinely the identical payload — was suppressed, and every
node the host had latched Bad stayed Bad until the plant value happened to change. Staling the status
first makes the identical replay a change. (A regression test for this must replay the **identical**
payload; a different value defeats the suppression on its own and proves nothing.)
**Two "not communicating" codes, deliberately.** `BadNoCommunication` (0x80310000) means *the link is
down* — the connection-loss stale, and what the host's comms-loss fan-out writes onto the value nodes.
`BadCommunicationError` (0x80050000) keeps its narrower Sparkplug meaning: the **source announced its
own death** (NDEATH/DDEATH), or a birth voided the scope feeding the tag. A refused SUBSCRIBE filter
also degrades to `BadCommunicationError`.
**Operator-visible change:** an MQTT tag now goes Bad when the broker session drops, rather than
holding its last value at Good until the host's 90 s comms-loss watchdog notices — which, since
MQTTnet usually reconnects well inside that window, in practice meant never.
## Write-through
`IWritable` landed with [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508). Before it,
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// <summary>
@@ -18,6 +20,34 @@ public interface IVirtualTagEvaluator
/// <param name="dependencies">Read-only dictionary of variable names to values for expression evaluation.</param>
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
/// <summary>
/// Evaluate <paramref name="expression"/> against dependency <b>snapshots</b> — value, status
/// code and source timestamp per input — so a script's <c>ctx.GetTag(path).StatusCode</c> reports
/// what the source actually said (archreview 02/S14).
/// </summary>
/// <remarks>
/// <b>Default-implemented on purpose.</b> It projects to values and calls the older overload, so
/// every existing implementation — the Roslyn evaluator aside, that is the null evaluator and the
/// test fakes — compiles and behaves unchanged; an implementation that can do better (the Roslyn
/// one) overrides it. Making it abstract would have been a breaking change for a fix whose whole
/// point is that it changes nothing on the Good path.
/// <para>The default necessarily discards the status codes it was given, which is exactly the
/// laundering this overload exists to end — so a consumer that cares must call an implementation
/// that overrides it.</para>
/// </remarks>
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
/// <param name="expression">The expression string to evaluate.</param>
/// <param name="dependencies">Read-only dictionary of dependency snapshots, keyed by tag path.</param>
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var values = new Dictionary<string, object?>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies) values[kv.Key] = kv.Value?.Value;
return Evaluate(virtualTagId, expression, values);
}
}
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so
@@ -104,6 +104,15 @@ public static class OtOpcUaTelemetry
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
/// <summary>Archreview 03/S15 — tag refs a driver left <c>BadNoCommunication</c> after it reconnected:
/// the host marked them Bad on the outage, and the driver never re-published them. Non-zero means a
/// driver that reconnects without a full re-publish (a push driver missing its initial push, or a
/// value-only dedup swallowing an unchanged value), which leaves those nodes Bad indefinitely.
/// Tagged driver + driver_type.</summary>
public static readonly Counter<long> DriverCommsLatchedRefs =
Meter.CreateCounter<long>("otopcua.driver.comms_latched_refs", unit: "{ref}",
description: "Refs still BadNoCommunication after their driver reconnected (driver, driver_type).");
/// <summary>Inbound operations denied by the Primary data-plane gate (archreview 03/S4) —
/// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown.</summary>
public static readonly Counter<long> PrimaryGateDenied =
@@ -32,6 +32,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// (actors inject THIS wrapper, not the inner SdkAddressSpaceSink) — same trap as WireAlarmNotifiers above.
public void WriteValues(IReadOnlyList<ValueWrite> values) => _inner.WriteValues(values);
/// <inheritdoc />
// Forward the status-only batch (03/S15). Unforwarded, the interface's DEFAULT runs here instead —
// and that default writes a NULL value, so a comms-loss annotation would erase the last known value
// of every node on the driver rather than retaining it, on every driver-role host (actors inject
// THIS wrapper, not the inner SdkAddressSpaceSink). Same trap as WriteValues / WireAlarmNotifiers
// above, except this one degrades to wrong data rather than to slow.
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses) => _inner.WriteStatuses(statuses);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
@@ -42,6 +42,31 @@ public interface IOpcUaAddressSpaceSink
}
}
/// <summary>Batched <b>status-only</b> write: each node keeps its CURRENT value and only its
/// StatusCode + SourceTimestamp change. This is the comms-loss annotation for value variables
/// (archreview 03/S15) — a driver that has stopped communicating leaves the node holding its last
/// known value (the Part 8 convention SCADA clients expect) under a
/// <see cref="OpcUaStatus.BadNoCommunication"/> status, rather than nulling it.
/// <para>A sink that HOLDS the node's value must retain it — the SDK-backed sink does, because the
/// node itself is where the value lives. This DEFAULT implementation cannot: the host holds no value
/// cache (v3's single-source design), so it degrades to <see cref="WriteValue"/> with a null value,
/// which is the honest statement for a value-less sink and keeps every fake compiling unchanged.</para>
/// <para>Same per-item isolation contract as <see cref="WriteValues"/>. An unknown or
/// unmaterialised NodeId is a <b>no-op, never a create</b>: a status carries nothing to create a node
/// from, and the caller's index can legitimately name a node a surgical remove has just dropped.</para></summary>
/// <param name="statuses">The status writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">A batching implementation throws this when some — but not
/// necessarily all — items failed; every other item has been applied.</exception>
void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
ArgumentNullException.ThrowIfNull(statuses);
for (var i = 0; i < statuses.Count; i++)
{
var s = statuses[i];
WriteValue(s.NodeId, null, s.StatusCode, s.SourceTimestampUtc, s.Realm);
}
}
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
/// this projects the whole <see cref="AlarmConditionSnapshot"/>
@@ -190,6 +215,16 @@ public interface IOpcUaAddressSpaceSink
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// <summary>One status-only write within a <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> batch: the
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
/// <paramref name="SourceTimestampUtc"/> change.</summary>
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
public readonly record struct StatusWrite(
string NodeId, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// <summary>Aggregate outcome of a partially-failed <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch:
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.</summary>
/// <remarks>A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
@@ -0,0 +1,74 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
/// the Calculation driver.
///
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
/// consumer never reports a quality better than its source's. A node derived from driver values
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
/// stopped communicating is a Bad input for everything downstream of it.</para>
///
/// <para><b>Why it lives here.</b> Core.Abstractions is the one assembly all three seams already
/// reference (Commons, Runtime and the driver assemblies all sit above it), and
/// <see cref="DataValueSnapshot"/> — the shape the rule reads — is declared beside it. Stating
/// the rule three times is how the three seams drifted apart in the first place.</para>
///
/// <para><b>Severity, not equality.</b> Comparison is on the OPC UA Part 4 severity bits (the top
/// two: <c>00</c> Good, <c>01</c> Uncertain, <c>10</c>/<c>11</c> Bad); the sub-code below them is
/// carried through verbatim because the sub-code is the diagnostic
/// (<c>BadNoCommunication</c> = go look at the device, <c>BadWaitingForInitialData</c> = it never
/// arrived, <c>BadInternalError</c> = the script).</para>
/// </summary>
public static class InputQuality
{
/// <summary>
/// No value has ever been received for this input — the status a fresh node is materialised
/// with, and what <see cref="WorstOf"/> reports for a dependency slot that holds no snapshot
/// at all. Spelled here rather than taken from <c>Commons.OpcUa.OpcUaStatus</c> because
/// Core.Abstractions sits <em>below</em> Commons and deliberately references nothing.
/// </summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The worst status among <paramref name="inputs"/>: the highest Part 4 severity, carrying
/// that input's own sub-code. A <see langword="null"/> entry is a dependency that has never
/// been pushed and reports <see cref="BadWaitingForInitialData"/> — reporting Good for it is
/// the laundering this helper exists to stop. An empty set is Good: there is nothing to be
/// worse than.
/// </summary>
/// <param name="inputs">The dependency snapshots, in a stable order.</param>
/// <returns>The worst status code observed.</returns>
/// <remarks>
/// Among <em>equal</em> severity the first entry seen wins, so a re-evaluation over the same
/// inputs reports the same sub-code instead of oscillating between two equally-bad codes.
/// </remarks>
public static uint WorstOf(IEnumerable<DataValueSnapshot?> inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
var worst = 0u;
var worstSeverity = 0u;
foreach (var input in inputs)
{
var status = input?.StatusCode ?? BadWaitingForInitialData;
var severity = status >> 30;
if (severity <= worstSeverity) continue;
worstSeverity = severity;
worst = status;
}
return worst;
}
/// <summary>
/// True when a consumer may evaluate against a value carrying <paramref name="statusCode"/>.
/// Good and Uncertain are usable — an Uncertain value is present and a script can make a
/// qualified decision from it; Bad is not, and a consumer holds instead (the alarm engine's
/// long-standing policy, generalised).
/// </summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns><see langword="true"/> for Good or Uncertain severity.</returns>
public static bool IsUsable(uint statusCode) => (statusCode >> 30) < 2;
}
@@ -919,26 +919,18 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
/// observation.</para></summary>
/// observation.</para>
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
/// other two seams treat a never-pushed input as Bad rather than as absent.</para></summary>
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
uint worst = 0u;
var worstSeverity = 0u;
foreach (var kv in cache)
{
// No entry at all — nothing was observed, so there is nothing to report on.
if (kv.Value is null) continue;
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
if (kv.Value.StatusCode == StatusNeverPushed) continue;
var status = kv.Value.StatusCode;
var severity = status >> 30;
if (severity > worstSeverity)
{
worstSeverity = severity;
worst = status;
}
}
return worst;
// No entry at all — nothing was observed, so there is nothing to report on.
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
var observed = cache.Values.Where(v => v is not null && v.StatusCode != StatusNeverPushed);
return InputQuality.WorstOf(observed);
}
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
@@ -82,7 +82,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// SHORT-HELD state lock — it is taken for dictionary work only and is NEVER held across a script run
// (Driver.Calculation-005).
private readonly object _lock = new();
private readonly Dictionary<string, object?> _depValues = new(StringComparer.Ordinal);
// Dependency SNAPSHOTS, not bare values (archreview 05/STAB-19): the status code is what tells a
// comms-lost input apart from a genuine Good null, and storing values only is what let this driver
// compute a Good output from a dead source.
private readonly Dictionary<string, DataValueSnapshot> _depValues = new(StringComparer.Ordinal);
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);
@@ -332,7 +335,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
if (!_running) return;
lock (_lock)
{
_depValues[rawPath] = value;
_depValues[rawPath] = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
_arrivedDeps.Add(rawPath);
if (!_changeDependents.TryGetValue(rawPath, out var dependents) || dependents.Count == 0) return;
@@ -391,9 +394,16 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
var now = DateTime.UtcNow;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
// Gated on HAS PUBLISHED, not on "has a value": a tag whose only publish was an inputs-Bad
// report has no value of its own, and answering BadWaitingForInitialData for it would tell a
// polled reader "never observed" while the subscriber on the same node had been told
// BadNoCommunication. Something HAS been observed about such a tag — that its input is dead.
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasPublished)
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
// Exactly what the subscriber was last told — including a propagated input sub-code. Deriving
// it from a two-state flag here would make a read disagree with the subscription over the
// same node (05/STAB-19).
var status = st.LastPublishedStatus;
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
}
}
@@ -404,12 +414,16 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// trigger fired, and that trigger's timestamp. Built under <see cref="_lock"/> by
/// <see cref="PrepareEvaluation"/> and executed outside it, so the script itself needs no shared state.</summary>
/// <param name="Definition">The calc tag being evaluated.</param>
/// <param name="Dependencies">Its declared reads' values, as of the trigger.</param>
/// <param name="Dependencies">Its declared reads' snapshots — value, status and timestamps — as of the trigger.</param>
/// <param name="TimestampUtc">The trigger's timestamp, stamped on whatever is published.</param>
/// <param name="WorstInputStatus">The worst status among <paramref name="Dependencies"/>
/// (<see cref="InputQuality.WorstOf"/>). Bad ⇒ the script is HELD and this status is published;
/// Good or Uncertain ⇒ the script runs and its result is stamped with it.</param>
private sealed record PendingEvaluation(
CalculationTagDefinition Definition,
IReadOnlyDictionary<string, object?> Dependencies,
DateTime TimestampUtc);
IReadOnlyDictionary<string, DataValueSnapshot> Dependencies,
DateTime TimestampUtc,
uint WorstInputStatus);
/// <summary>
/// Prepare one calc tag's evaluation: resolve the definition, apply the change-gate, and snapshot
@@ -441,10 +455,14 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return null; // unresolved script — never computes
if (!def.DependencyRefs.All(_arrivedDeps.Contains)) return null; // still warming up — do not evaluate
var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
var deps = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var dep in def.DependencyRefs)
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
return new PendingEvaluation(def, deps, timestampUtc);
// Worst-of-input, computed from the SAME snapshot the evaluation will use, so the status
// published can never disagree with the values it was derived from (05/STAB-19).
var worst = InputQuality.WorstOf(def.DependencyRefs.Select(r => deps.GetValueOrDefault(r)));
return new PendingEvaluation(def, deps, timestampUtc, worst);
}
/// <summary>Start the evaluation pump if it is not already running. MUST be called under
@@ -513,6 +531,28 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
private void RunEvaluation(PendingEvaluation pending)
{
var def = pending.Definition;
// HOLD ON BAD (05/STAB-19, matching ScriptedAlarmEngine's AreInputsReady): a Bad input is not
// usable, so the script does NOT run — which removes the "(double)ctx.GetTag(x).Value threw"
// storm at its source rather than catching it downstream — and the tag reports the input's own
// sub-code. Uncertain IS usable: the script runs and the output inherits the Uncertain code.
if (!InputQuality.IsUsable(pending.WorstInputStatus))
{
(object? Value, uint StatusCode)? badPublish;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(def.RawPath, out var badState)) return;
badPublish = MarkInputsBad(badState, pending.WorstInputStatus, pending.TimestampUtc);
}
// No script log: an input dying is not a script fault, and the script log is the per-failure
// SCRIPT diagnostic. Here the status code is the diagnostic.
if (badPublish is { } bad) Publish(def.RawPath, bad.Value, bad.StatusCode, pending.TimestampUtc);
return;
}
// Snapshots, not bare values: the script's ctx.GetTag(path).StatusCode / .SourceTimestampUtc are
// then the input's real ones rather than a fabricated Good stamped at evaluation time (05/STAB-19).
var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, pending.Dependencies);
string? failureReason = null;
@@ -530,7 +570,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
else
{
toPublish = MarkEvaluationSucceeded(st, result.Value, pending.TimestampUtc);
toPublish = MarkEvaluationSucceeded(st, result.Value, pending.WorstInputStatus, pending.TimestampUtc);
}
}
@@ -539,23 +579,51 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
/// <summary>Record a successful evaluation. MUST be called under <see cref="_lock"/>; returns what to
/// publish, or null when the value is deduped. The dedup is bypassed while recovering from Bad so a
/// recovery whose value equals the pre-failure value still republishes Good (otherwise the node would
/// stay Bad forever).</summary>
/// publish, or null when the result is deduped.
/// <para><b>The dedup key is (value, status), not value alone</b> — 05/STAB-19 generalising the
/// Bad-recovery bypass 02/S13 introduced. Recovery is a publish, never an assumption: the node is
/// currently carrying the previous status, so a result whose VALUE is unchanged but whose STATUS
/// differs — the shape of every recovery, since a device that comes back usually comes back holding
/// the same reading — must still land, or the node stays Bad forever.</para></summary>
/// <param name="st">The tag's published state.</param>
/// <param name="value">The computed value.</param>
/// <param name="statusCode">The status to stamp: the worst input status (Good, or an Uncertain sub-code).</param>
/// <param name="timestampUtc">The trigger's timestamp.</param>
/// <returns>The snapshot to publish, or null when deduped.</returns>
private (object? Value, uint StatusCode)? MarkEvaluationSucceeded(TagState st, object? value, DateTime timestampUtc)
private (object? Value, uint StatusCode)? MarkEvaluationSucceeded(
TagState st, object? value, uint statusCode, DateTime timestampUtc)
{
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, value)) return null;
if (st.HasPublished && st.LastPublishedStatus == statusCode && st.HasValue && Equals(st.LastValue, value))
return null;
st.HasValue = true;
st.LastValue = value;
st.LastPublishedBad = false;
st.HasPublished = true;
st.LastPublishedStatus = statusCode;
st.LastTimestampUtc = timestampUtc;
_lastComputeUtc = DateTime.UtcNow;
return (value, StatusGood);
return (value, statusCode);
}
/// <summary>Record that this tag's INPUTS are Bad, so no evaluation ran (05/STAB-19). Publishes the
/// worst input's status — carrying the last known value — <b>once per change of that status</b>: a
/// repeat of the same Bad is silent, while <c>BadNoCommunication</c> arriving over an earlier
/// <c>BadInternalError</c> (or the reverse) is not masked, because the sub-code is what names which
/// failure the operator is looking at. MUST be called under <see cref="_lock"/>.</summary>
/// <param name="st">The tag's published state.</param>
/// <param name="worstInputStatus">The worst status among the tag's declared inputs.</param>
/// <param name="timestampUtc">The trigger's timestamp.</param>
/// <returns>The snapshot to publish, or null when that status was already published.</returns>
private static (object? Value, uint StatusCode)? MarkInputsBad(
TagState st, uint worstInputStatus, DateTime timestampUtc)
{
if (st.HasPublished && st.LastPublishedStatus == worstInputStatus) return null;
st.HasPublished = true;
st.LastPublishedStatus = worstInputStatus;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with the input's Bad status.
return (st.HasValue ? st.LastValue : null, worstInputStatus);
}
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): the caller always emits the
@@ -568,9 +636,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <returns>The Bad snapshot to publish on a fresh Good→Bad transition, else null.</returns>
private static (object? Value, uint StatusCode)? MarkEvaluationFailed(TagState st, DateTime timestampUtc)
{
if (st.LastPublishedBad) return null;
if (st.HasPublished && st.LastPublishedStatus == StatusBadInternalError) return null;
st.LastPublishedBad = true;
st.HasPublished = true;
st.LastPublishedStatus = StatusBadInternalError;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with Bad quality.
return (st.HasValue ? st.LastValue : null, StatusBadInternalError);
@@ -643,7 +712,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
{
public bool HasValue;
public object? LastValue;
public bool LastPublishedBad;
/// <summary>Whether anything has been published for this tag yet. Distinct from
/// <see cref="HasValue"/>: an inputs-Bad publish carries no value of its own.</summary>
public bool HasPublished;
/// <summary>The status code last published for this tag. Replaces the old two-state
/// <c>LastPublishedBad</c> flag (05/STAB-19): the dedup key is (value, status), and a Bad status
/// now carries the failing INPUT's sub-code rather than a fixed <c>BadInternalError</c>.</summary>
public uint LastPublishedStatus;
public DateTime LastTimestampUtc;
}
@@ -75,7 +75,44 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <remarks>
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
/// evaluation-time timestamp, because bare values carry nothing better. Prefer
/// <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>, which the
/// driver uses: it carries each input's real status and source timestamp, so
/// <c>ctx.GetTag(path).StatusCode</c> is honest (archreview 05/STAB-19). This overload remains for
/// callers that genuinely hold only values — chiefly tests.
/// </remarks>
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var nowUtc = DateTime.UtcNow;
var snapshots = new Dictionary<string, DataValueSnapshot>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies)
snapshots[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, nowUtc, nowUtc);
return Evaluate(calcTagId, scriptSource, snapshots);
}
/// <summary>
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
/// the dependency <b>snapshots</b> in <paramref name="dependencies"/> — value, status code and
/// source timestamp per input. Never throws; script faults surface as
/// <see cref="VirtualTagEvalResult.Failure"/>.
/// </summary>
/// <remarks>
/// The snapshots pass <b>through</b> to the script's read cache rather than being re-wrapped as
/// Good (archreview 05/STAB-19). The old wrapping meant a script that inspected
/// <c>ctx.GetTag(path).StatusCode</c> was told Good for every input, including one whose driver had
/// stopped communicating — so a quality-aware script could not be written at all. The driver holds
/// evaluation entirely while any input is Bad, so what a script sees here is Good or Uncertain.
/// </remarks>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <returns>The evaluation outcome.</returns>
public VirtualTagEvalResult Evaluate(
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
@@ -86,12 +123,15 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
{
return dependencies.TryGetValue(passthroughRef, out var ptValue)
? VirtualTagEvalResult.Ok(ptValue)
// The VALUE only — the caller stamps the output with the worst input status, which for a
// single-dependency passthrough IS this input's status. Returning it here as well would put
// the same decision in two places.
return dependencies.TryGetValue(passthroughRef, out var ptSnapshot)
? VirtualTagEvalResult.Ok(ptSnapshot.Value)
: VirtualTagEvalResult.Ok(null);
}
var readCache = BuildReadCache(dependencies);
var readCache = dependencies;
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
var scriptLog = _scriptRoot
@@ -173,20 +213,6 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
public void Dispose()
{
@@ -349,6 +349,31 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
/// </remarks>
public event Func<CancellationToken, Task>? Reconnected;
/// <summary>
/// Raised on the <c>Connected → Reconnecting</c> edge — the session this connection had is
/// gone. Ingest paths stale their cached values to <c>BadNoCommunication</c> here (archreview
/// 05/STAB-18): without it, MQTT reports Good over a dead broker until the host's 90 s
/// comms-loss watchdog notices, and MQTTnet's own supervisor usually reconnects well inside
/// that window so the outage is never surfaced at all.
/// </summary>
/// <remarks>
/// <para>
/// <b>Runs on MQTTnet's dispatcher thread</b> (this is raised from the library's
/// <c>DisconnectedAsync</c> callback), so a handler must be non-blocking, must not do I/O
/// and must not await — the same constraint <see cref="MessageReceived"/> carries. A
/// throwing handler is caught and logged rather than escaping into the pump.
/// </para>
/// <para>
/// <b>Exactly once per edge, and only for a real outage.</b> It does not fire for a failed
/// <i>initial</i> connect (there was no session to lose — the values are still
/// <c>BadWaitingForInitialData</c>) nor during <see cref="DisposeAsync"/> (an intentional
/// teardown, which every redeploy performs). It is deliberately NOT the mirror of
/// <see cref="Reconnected"/>: recovery is a value the broker republishes, never an
/// assumption this connection makes on the caller's behalf.
/// </para>
/// </remarks>
public event Action? ConnectionLost;
/// <summary>
/// Raised for every inbound application message, on MQTTnet's own dispatcher thread. This
/// connection does no routing, parsing or typing of its own — that is the subscription
@@ -939,6 +964,19 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
if (State == MqttConnectionState.Connected)
{
SetState(MqttConnectionState.Reconnecting);
// The quality edge (05/STAB-18): exactly here, once per outage, and never for an initial
// connect failure or a dispose — both of which are excluded by the state guard above.
// Contained, because this runs on the library's dispatcher: a throwing ingest handler must
// not stop the reconnect wake below.
try
{
ConnectionLost?.Invoke();
}
catch (Exception ex)
{
_logger?.LogError(ex, "MQTT driver '{DriverId}': a connection-lost handler threw.", _driverId);
}
}
// Released unconditionally rather than only when the client had been connected: a missed
@@ -89,6 +89,16 @@ public sealed class MqttSubscriptionManager
/// <summary>The broker refused, or never answered, the SUBSCRIBE for this reference's topic.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// No value has ever been observed for this reference — <see cref="LastValueCache.Read"/>'s
/// answer for a RawPath nothing has published under yet. The initial push skips it: pushing
/// this would claim an observation the driver has not made.
/// </summary>
private const uint StatusBadWaitingForInitialData = 0x80320000u;
/// <summary>The broker session this driver was observing is gone — see <see cref="OnConnectionLost"/>.</summary>
private const uint StatusBadNoCommunication = 0x80310000u;
private readonly int _defaultQos;
private readonly string _driverId;
private readonly ILogger? _logger;
@@ -230,6 +240,45 @@ public sealed class MqttSubscriptionManager
_transport = connection;
connection.MessageReceived += HandleMessage;
connection.Reconnected += OnReconnectedAsync;
connection.ConnectionLost += OnConnectionLost;
}
/// <summary>
/// Marks every already-observed reference <c>BadNoCommunication</c>, <b>keeping its last
/// value</b>, and publishes the change — the driver's answer to a lost broker session
/// (archreview 05/STAB-18 shape a). Wired to <see cref="MqttConnection.ConnectionLost"/> by
/// <see cref="AttachTo"/>; public so tests (and Sparkplug's ingestor) can drive the edge
/// directly.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is what makes the reconnect recover.</b> The steady-state suppression in
/// <see cref="Deliver"/> compares value AND status against the driver-owned cache, which
/// survives reconnects and redeploys — so the retained replay of an unchanged payload was
/// suppressed and every node latched Bad by the host stayed Bad until the plant value
/// happened to change. Staling the status first makes that identical replay a change.
/// </para>
/// <para>
/// A never-observed reference is left alone (it is already
/// <see cref="StatusBadWaitingForInitialData"/>, which says something more precise), and an
/// already-Bad one is not re-stamped. Runs on MQTTnet's dispatcher thread: dictionary work
/// and synchronous <see cref="OnDataChange"/> invokes only — exactly what
/// <see cref="Deliver"/> already does there — and never awaits.
/// </para>
/// </remarks>
public void OnConnectionLost()
{
var now = DateTime.UtcNow;
foreach (var rawPath in _authored.ByRawPath.Keys)
{
var prior = Values.Read(rawPath);
if (prior.StatusCode == StatusBadWaitingForInitialData) continue; // never observed
if ((prior.StatusCode >> 30) >= 2) continue; // already Bad
var staled = prior with { StatusCode = StatusBadNoCommunication, ServerTimestampUtc = now };
Values.Update(rawPath, staled);
Raise(rawPath, staled);
}
}
/// <summary>
@@ -356,6 +405,13 @@ public sealed class MqttSubscriptionManager
_handleByRawPath[def.Name] = handle;
}
// Initial push (05/STAB-18 shape b): every ref this driver has already observed is republished
// onto the new handle before any broker traffic. A poll driver gets this free — PollGroupEngine
// runs the first poll of a new subscription with forceRaise: true — and CalculationDriver does
// it explicitly; MQTT did not, so a node reseeded BadWaitingForInitialData by an address-space
// rebuild stayed there until the plant published again, which for a quiet topic is never.
PushObservedSnapshots(defs);
// One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an
// established filter is legal but pointless, and it re-triggers the retained seed.
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList();
@@ -756,6 +812,12 @@ public sealed class MqttSubscriptionManager
// timestamp — and only the notification is skipped. "Cached but not raised" is already a valid
// state on this path (see Raise: an authored-but-unsubscribed tag does exactly that).
//
// What makes this suppression SAFE is the pair of edge behaviours around it (05/STAB-18):
// PushObservedSnapshots re-delivers the cached snapshot on every (re)subscribe, and
// OnConnectionLost stales every observed ref to BadNoCommunication — so the reconnect's
// retained replay of an unchanged payload differs in STATUS and is not suppressed. Without
// those, this comparison is what pinned a comms-lost tag Bad until the plant value changed.
//
// Sparkplug bypasses this entirely. SparkplugIngestor.cs updates Values and raises directly
// (its own Values.Update()/Raise() calls, not through this Deliver method), so Sparkplug B
// ingestion gets no unchanged-value suppression — deliberately out of scope here.
@@ -788,6 +850,29 @@ public sealed class MqttSubscriptionManager
return !Equals(previous, current);
}
/// <summary>
/// Republishes the cached snapshot of every already-observed reference in
/// <paramref name="defs"/> — the push-model equivalent of <c>PollGroupEngine</c>'s
/// <c>forceRaise: true</c> on the first poll of a new subscription (05/STAB-18 shape b).
/// </summary>
/// <remarks>
/// A never-observed reference is skipped: its cached status is
/// <see cref="StatusBadWaitingForInitialData"/>, and republishing that would assert an
/// observation this driver has not made. The cache is deliberately NOT re-<c>Update</c>d here —
/// nothing new was observed, only re-delivered, so the stored timestamps stay honest and the
/// steady-state suppression still compares against the real last observation.
/// </remarks>
/// <param name="defs">The definitions the new handle covers.</param>
private void PushObservedSnapshots(IReadOnlyList<MqttTagDefinition> defs)
{
for (var i = 0; i < defs.Count; i++)
{
var snapshot = Values.Read(defs[i].Name);
if (snapshot.StatusCode == StatusBadWaitingForInitialData) continue;
Raise(defs[i].Name, snapshot);
}
}
/// <summary>
/// Raises <see cref="OnDataChange"/> for a reference, if a live subscription covers it.
/// A throwing subscriber is contained so it cannot starve the rest of the fan-out — or, worse,
@@ -101,9 +101,28 @@ public sealed class SparkplugIngestor
/// OpcUaClient and this driver's own plain-mode path all emit it, while no driver emits
/// BadNoCommunication at all (it appears once, in a CLI formatter's label map). Following the
/// precedent keeps one code meaning "not communicating" across the whole fleet.
/// <para><b>Superseded in part (archreview R3-02):</b> <see cref="StatusBadNoCommunication"/> now
/// has a distinct, fleet-wide meaning — "the link to the source is down" — written by the host's
/// comms-loss fan-out and by <see cref="OnConnectionLost"/>. This code keeps the narrower one it
/// always had here: the <i>source itself</i> announced it is offline, or a birth voided the
/// scope.</para>
/// </summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// The broker session this driver was observing is gone — see <see cref="OnConnectionLost"/>.
/// Distinct from <see cref="StatusBadCommunicationError"/>, which means the source announced its
/// own death.
/// </summary>
private const uint StatusBadNoCommunication = 0x80310000u;
/// <summary>
/// No value has ever been observed for this reference — <c>LastValueCache.Read</c>'s answer for
/// a RawPath nothing has published under yet. Neither the initial push nor the connection-loss
/// stale touches such a reference: both would claim an observation that never happened.
/// </summary>
private const uint StatusBadWaitingForInitialData = 0x80320000u;
/// <summary>The metric's value does not fit the tag's effective data type — including an explicit Sparkplug null.</summary>
private const uint StatusBadTypeMismatch = 0x80740000u;
@@ -317,6 +336,47 @@ public sealed class SparkplugIngestor
_publishTransport = connection;
connection.MessageReceived += HandleMessage;
connection.Reconnected += OnReconnectedAsync;
connection.ConnectionLost += OnConnectionLost;
}
/// <summary>
/// Marks every already-observed metric <c>BadNoCommunication</c>, <b>keeping its last value</b>,
/// and publishes the change — this driver's answer to a lost broker session (archreview
/// 05/STAB-18 shape a). Wired to <see cref="MqttConnection.ConnectionLost"/> by
/// <see cref="AttachTo"/>; public so tests can drive the edge directly.
/// </summary>
/// <remarks>
/// <para>
/// <b>A death is a different event and keeps its own code.</b> NDEATH/DDEATH is the source
/// announcing it is offline and stales with <see cref="StatusBadCommunicationError"/> as it
/// always has; this is <i>our</i> transport losing the broker, and it reports
/// <see cref="StatusBadNoCommunication"/> — the same code the host's comms-loss fan-out
/// writes onto the value nodes, so an operator sees one story from both ends.
/// </para>
/// <para>
/// <b>It is what makes the reconnect recover.</b> A reconnect replays NBIRTH/DBIRTH carrying
/// values that are routinely unchanged; the birth path publishes them, but every node the
/// host latched Bad needs a change to un-latch. Staling first guarantees the replay differs.
/// </para>
/// <para>
/// Runs on MQTTnet's dispatcher thread: dictionary reads and synchronous
/// <see cref="OnDataChange"/> invokes only, never an await. A never-observed metric and an
/// already-Bad one are both left alone.
/// </para>
/// </remarks>
public void OnConnectionLost()
{
var now = _utcNow();
foreach (var rawPath in _authored.ByRawPath.Keys)
{
var prior = Values.Read(rawPath);
if (prior.StatusCode == StatusBadWaitingForInitialData) continue; // never observed
if ((prior.StatusCode >> 30) >= 2) continue; // already Bad
var staled = prior with { StatusCode = StatusBadNoCommunication, ServerTimestampUtc = now };
Values.Update(rawPath, staled);
Raise(rawPath, staled);
}
}
/// <summary>
@@ -502,6 +562,13 @@ public sealed class SparkplugIngestor
_handleByRawPath[def.Name] = handle;
}
// Initial push (05/STAB-18 shape b): every metric this driver has already observed is
// republished onto the new handle. A poll driver gets this free from PollGroupEngine's
// forceRaise on the first poll of a new subscription; Sparkplug pushed nothing, so an
// address-space rebuild — which reseeds every node BadWaitingForInitialData and triggers no
// rebirth — left a quiet metric waiting for the plant indefinitely.
PushObservedSnapshots(defs);
return Task.FromResult<ISubscriptionHandle>(handle);
}
@@ -580,10 +647,16 @@ public sealed class SparkplugIngestor
/// already empty, so calling it three times during one connect costs three no-ops.
/// </para>
/// <para>
/// Values are deliberately <b>not</b> staled here. A session change is a driver-side event,
/// not a statement about the plant; last observed values remain the best available answer
/// until a birth lands, and the connection's own health surface already reports the outage.
/// Only a death — the source saying it is offline — stales a tag.
/// <b>Values are not staled here — but they ARE staled on a connection loss</b> (archreview
/// R3-02; this remark used to claim the opposite and that was the stale-Good bug). What is
/// still true is the narrow part: a session reset for a <i>config rebuild</i> on a live
/// connection says nothing about the plant, so last observed values remain the best answer
/// until a birth lands. What was wrong was the reason given for the general case — "the
/// connection's own health surface already reports the outage". Health is not quality: an
/// OPC UA client subscribed to the metric's node sees neither the health page nor the
/// outage, and reported Good over a dead broker. The transport loss is now handled by
/// <see cref="OnConnectionLost"/> (<see cref="StatusBadNoCommunication"/>, value retained);
/// a death still stales the way it always did (<see cref="StatusBadCommunicationError"/>).
/// </para>
/// </remarks>
/// <param name="reason">Why the reset is happening, for the diagnostic log.</param>
@@ -1376,6 +1449,28 @@ public sealed class SparkplugIngestor
return new DataValueSnapshot(null, StatusBadTypeMismatch, stamp, serverUtc);
}
/// <summary>
/// Republishes the cached snapshot of every already-observed reference in
/// <paramref name="defs"/> — the push-model equivalent of <c>PollGroupEngine</c>'s
/// <c>forceRaise: true</c> on the first poll of a new subscription (05/STAB-18 shape b).
/// </summary>
/// <remarks>
/// A never-observed reference is skipped: its cached status is
/// <see cref="StatusBadWaitingForInitialData"/>, which is a more precise statement than any
/// value this could republish. The cache is not re-<c>Update</c>d — nothing new was observed,
/// only re-delivered — so the stored timestamps stay honest.
/// </remarks>
/// <param name="defs">The definitions the new handle covers.</param>
private void PushObservedSnapshots(IReadOnlyList<MqttTagDefinition> defs)
{
for (var i = 0; i < defs.Count; i++)
{
var snapshot = Values.Read(defs[i].Name);
if (snapshot.StatusCode == StatusBadWaitingForInitialData) continue;
Raise(defs[i].Name, snapshot);
}
}
/// <summary>Marks every authored tag in <paramref name="scope"/> as no longer communicating.</summary>
private void FanStale(SparkplugScope scope, DateTime? sourceUtc, string reason)
{
@@ -59,7 +59,34 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
}
/// <inheritdoc />
/// <remarks>
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
/// evaluation-time timestamp — bare values carry nothing better. The runtime calls the snapshot
/// overload instead (archreview 02/S14); this one remains for callers that genuinely hold only
/// values.
/// </remarks>
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var nowUtc = DateTime.UtcNow;
var snapshots = new Dictionary<string, DataValueSnapshot>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies)
snapshots[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, nowUtc, nowUtc);
return Evaluate(virtualTagId, expression, snapshots);
}
/// <inheritdoc />
/// <remarks>
/// Overrides the interface's default (which projects to values and loses the status codes): the
/// snapshots pass <b>through</b> as the script's read cache, so
/// <c>ctx.GetTag(path).StatusCode</c> and <c>.SourceTimestampUtc</c> are the input's own rather
/// than a fabricated <c>Good</c> stamped at evaluation time (archreview 02/S14). The virtual-tag
/// actor holds evaluation entirely while any input is Bad, so what a script observes here is Good
/// or Uncertain.
/// </remarks>
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
@@ -71,12 +98,14 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
// so the script returns null — Ok(null) here too.
if (PassthroughScript.TryMatch(expression, out var passthroughRef))
{
return dependencies.TryGetValue(passthroughRef, out var ptValue)
? VirtualTagEvalResult.Ok(ptValue)
// The VALUE only — the caller stamps the result with the worst input status, which for a
// single-dependency passthrough IS this input's status.
return dependencies.TryGetValue(passthroughRef, out var ptSnapshot)
? VirtualTagEvalResult.Ok(ptSnapshot?.Value)
: VirtualTagEvalResult.Ok(null);
}
var readCache = BuildReadCache(dependencies);
var readCache = dependencies;
// Per-evaluation script logger: bind both ScriptId and VirtualTagId from the virtual-tag id
// (in the live path the script id equals the virtual-tag id) so the Script-log page can
// attribute each line. EquipmentId stays unbound for now.
@@ -162,21 +191,6 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
// values stay null; the script can null-check via GetTag(path).Value.
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
/// <summary>Disposes the evaluator and cleans up all cached script evaluators.</summary>
public void Dispose()
{
@@ -649,6 +649,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>Apply a batch of <b>status-only</b> writes: each node keeps its CURRENT value and only its
/// StatusCode + Timestamp change (archreview 03/S15 — the comms-loss annotation for value variables).
/// Same shape as <see cref="WriteValues"/>: an outside-the-lock validate/resolve pre-pass, then
/// <see cref="MaxWritesPerLockAcquisition"/>-sized chunks under one <c>Lock</c> each, per-item isolation,
/// and a <see cref="ValueWriteBatchException"/> aggregate.
/// <para><b>It never creates a node.</b> A status carries nothing to create one from, and the caller's
/// per-driver ref index can legitimately name a node a surgical remove has just dropped — so an unknown
/// id is skipped, not materialised, and is not counted as a failure either (it is an expected race, not
/// a fault).</para></summary>
/// <param name="statuses">The status writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">When one or more items failed validation or threw. Every
/// other item HAS been applied.</exception>
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
ArgumentNullException.ThrowIfNull(statuses);
EnsureAddressSpaceCreated();
if (statuses.Count == 0) return;
var resolved = new ResolvedWrite[statuses.Count];
string? firstFailedNodeId = null;
Exception? firstFailure = null;
var failed = 0;
for (var i = 0; i < statuses.Count; i++)
{
var nodeId = statuses[i].NodeId;
if (string.IsNullOrEmpty(nodeId))
{
failed++;
firstFailedNodeId ??= nodeId ?? string.Empty;
firstFailure ??= new ArgumentException(
$"WriteStatuses item {i} has a null or empty NodeId.", nameof(statuses));
continue;
}
resolved[i] = new ResolvedWrite(
nodeId, NamespaceIndexForRealm(statuses[i].Realm), MapKey(statuses[i].Realm, nodeId), IsValid: true);
}
var succeeded = 0;
var skipped = 0;
for (var start = 0; start < statuses.Count; start += MaxWritesPerLockAcquisition)
{
var end = Math.Min(start + MaxWritesPerLockAcquisition, statuses.Count);
lock (Lock)
{
for (var i = start; i < end; i++)
{
if (!resolved[i].IsValid) continue;
var s = statuses[i];
try
{
if (WriteStatusLocked(resolved[i].Key, s.StatusCode, s.SourceTimestampUtc)) succeeded++;
else skipped++;
}
catch (Exception ex)
{
failed++;
firstFailedNodeId ??= s.NodeId;
firstFailure ??= ex;
}
}
}
}
if (skipped > 0)
{
// Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver
// whose nodes were all just removed would otherwise log once per ref. This manager carries no
// ILogger, so log via the SDK's static trace (see ServeNodeAsync).
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogDebug(
"OtOpcUaNodeManager: WriteStatuses skipped {0} of {1} item(s) — node not materialised.",
skipped,
statuses.Count);
#pragma warning restore CS0618
}
if (failed > 0)
{
throw new ValueWriteBatchException(
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
}
}
/// <summary>The body of a single status-only write. <b>Caller MUST hold <c>Lock</c></b>.</summary>
/// <param name="key">The realm-qualified map key.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the annotation in UTC.</param>
/// <returns><see langword="true"/> when the node existed and was stamped; <see langword="false"/> when
/// no such node is materialised (skipped — deliberately not a create; see <see cref="WriteStatuses"/>).</returns>
private bool WriteStatusLocked(RealmKey key, uint statusCode, DateTime sourceTimestampUtc)
{
if (!_variables.TryGetValue(key, out var variable)) return false;
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
// Bad status (OPC UA Part 8's last-known-value convention).
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
return true;
}
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
@@ -31,6 +31,13 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public void WriteValues(IReadOnlyList<ValueWrite> values)
=> _nodeManager.WriteValues(values);
/// <inheritdoc />
/// <remarks>Overrides the interface's default on purpose, and here the default is not merely slower but
/// <b>wrong</b>: it writes a null value (a value-less sink has nothing else to write), which would erase
/// the last known value this annotation exists to preserve. The manager's own entry point retains it.</remarks>
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
=> _nodeManager.WriteStatuses(statuses);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
@@ -91,6 +91,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// connect work, so it should not run more often than an ordinary reconnect attempt.</summary>
public static readonly TimeSpan DefaultReprovisionBackoff = TimeSpan.FromSeconds(10);
/// <summary>
/// How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs still
/// latched <see cref="OpcUaStatus.BadNoCommunication"/>. Sized so an ordinary poll driver has had
/// several poll cycles — and a slow one at least one — to re-publish before anything is said; the
/// audit's job is to name a driver that will NEVER re-publish, not to race a slow first poll.
/// </summary>
public static readonly TimeSpan DefaultCommsRecoveryAuditDelay = TimeSpan.FromSeconds(30);
/// <summary>
/// Cache key used when an artifact carries no cluster scoping (single-cluster or unscoped
/// deployments). A literal rather than an empty string so the row is visibly deliberate when
@@ -426,6 +434,27 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <see cref="DefaultApplyWedgeTimeout"/>).</summary>
private readonly TimeSpan _applyWedgeTimeout;
/// <summary>How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs
/// still latched <see cref="OpcUaStatus.BadNoCommunication"/> (see <see cref="CommsRecoveryAudit"/>).</summary>
private readonly TimeSpan _commsRecoveryAuditDelay;
/// <summary>
/// Archreview <b>03/S15</b> — RawPaths this host has stamped <c>BadNoCommunication</c> on the value
/// nodes of, per driver, and has not yet seen a value for. Set by
/// <see cref="FanCommsLossToValueNodes"/>, pruned by <see cref="ForwardBatchToMux"/> as each ref
/// re-publishes, and read once by <see cref="CommsRecoveryAudit"/> after the driver reconnects.
/// <para><b>It is a tripwire, not a mechanism.</b> Nothing depends on it being correct for the data
/// plane to behave: the host publishes nothing Good on reconnect either way. What it buys is that a
/// driver which reconnects and then fails to re-publish — the pre-STAB-18 MQTT shape, and what any
/// future push driver without an initial push will do — produces a Warning and a metered count
/// instead of a subscription that silently reports Bad forever.</para>
/// <para>Keyed by RawPath, so a raw node and its UNS mirrors count once: the operator is being told
/// how many <i>tags</i> did not come back, not how many address-space nodes they project onto. The
/// entry is removed as soon as it empties, so the steady-state cost in
/// <see cref="ForwardBatchToMux"/> is one dictionary miss per burst.</para>
/// </summary>
private readonly Dictionary<string, HashSet<string>> _commsLatched = new(StringComparer.Ordinal);
/// <summary>Per-driver rate limit for the supervision-reprovision hook (see
/// <see cref="DefaultReprovisionBackoff"/>).</summary>
private readonly TimeSpan _reprovisionBackoff;
@@ -483,6 +512,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
/// <summary>Self-sent by the 03/S15 audit timer, <see cref="_commsRecoveryAuditDelay"/> after a driver
/// reconnected: report any of its refs still latched <c>BadNoCommunication</c>. See
/// <see cref="HandleCommsRecoveryAudit"/>.</summary>
/// <param name="DriverInstanceId">The driver whose recovery is being audited.</param>
public sealed record CommsRecoveryAudit(string DriverInstanceId);
private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed)
{
// Convenience accessors for sites that don't need the full spec.
@@ -548,6 +583,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// VirtualTag + ScriptedAlarm hosts. Defaults to null (admin-only nodes / tests skip the emit).</param>
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>.</param>
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; tests shorten it.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
@@ -577,7 +615,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
IHistorizationRoleView? historizationRoleView = null,
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null) =>
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -589,7 +628,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff));
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -648,6 +687,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>; a test shortens
/// it so the watchdog is observable in a test's lifetime.</param>
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; a test shortens it for the same reason.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode,
@@ -676,10 +718,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
IHistorizationRoleView? historizationRoleView = null,
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null)
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null)
{
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay;
_historizationRoleView = historizationRoleView;
_sharesHistorizationScopeWithLocal = sharesHistorizationScopeWithLocal;
_telemetryHub = telemetryHub;
@@ -1110,6 +1154,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
@@ -1167,6 +1212,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
Receive<RestartDriver>(HandleRestartDriver);
@@ -1209,6 +1255,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// One envelope per burst; the mux filters per item (it registered a batch receive in Task 9).
_dependencyMux?.Tell(msg);
// 03/S15 recovery audit: clear whatever this burst re-published from the driver's Bad latch. Guarded
// by ONE dictionary probe per burst — with no outage outstanding (the steady state) this is a single
// miss, not per-item work.
if (_commsLatched.ContainsKey(msg.DriverInstanceId))
{
for (var i = 0; i < msg.Items.Count; i++)
TrackCommsLatchPrune(msg.DriverInstanceId, msg.Items[i].FullReference);
}
if (_opcUaPublishActor is null) return;
// Indexed for-loop, not foreach: IReadOnlyList<AttributeValueItem> enumerates through an interface
@@ -1285,13 +1340,34 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// the source had died. <see cref="FanCommsLossToDependencyMux"/> closes that by pushing a
/// synthetic Bad for every value ref the driver owns.
/// </para>
/// <para>
/// <b>Archreview 03/S15 — and the EXTERNAL data plane, which the two layers above still left
/// out.</b> Conditions and internal consumers learned of the outage; the driver's raw value
/// variables and their UNS mirrors — what an OPC UA client actually subscribes to — did not, so
/// they served the last value at <c>Good</c> for the whole outage.
/// <see cref="FanCommsLossToValueNodes"/> closes that with one status-only batch that keeps each
/// node's value and changes only its status. All three fan-outs run on the disconnect edge only;
/// the reconnect edge writes nothing to values (recovery is the driver's own re-publish) and
/// re-annotates conditions Good, which is a statement about the LINK, not about any tag's value.
/// </para>
/// </summary>
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
// #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
// publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
// return for exactly that reason.
if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId);
if (!msg.Connected)
{
FanCommsLossToDependencyMux(msg.DriverInstanceId);
// 03/S15: and the EXTERNAL data plane, which #481 stopped one seam short of.
FanCommsLossToValueNodes(msg.DriverInstanceId);
}
else
{
// 03/S15: nothing Good is published here — instead, check LATER whether the driver republished
// what this host marked Bad. See HandleCommsRecoveryAudit.
TrackCommsLatchArmAudit(msg.DriverInstanceId);
}
if (_opcUaPublishActor is null) return;
@@ -1323,6 +1399,160 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
}
/// <summary>
/// Archreview <b>03/S15</b> — the value-node half of the comms-loss fan-out: stamp
/// <see cref="OpcUaStatus.BadNoCommunication"/> on every raw NodeId <b>and</b> every referencing UNS
/// NodeId the just-disconnected driver owns, as ONE status batch through the same publish actor the
/// value fan-out uses.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why it was missing.</b> #481 built the per-driver ref index and wired comms loss to the
/// internal consumers (the mux) and to Part 9 conditions, and stopped one seam short of the
/// external OPC UA data plane. A client subscribed to a value node therefore saw the last value
/// at <b>Good</b> quality for the whole outage, for every driver whose failure shape is "the
/// reader stops producing" rather than "publish a Bad snapshot per tag". (Modbus does the
/// latter, quickly, which is why the gap looked smaller than it was — a coalesced-read throw, a
/// dropped session on a push driver, or simply a driver whose last act was a Good publish all
/// held Good indefinitely.)
/// </para>
/// <para>
/// <b>Status-only, so the VALUE is retained.</b> This actor holds no value cache — v3's
/// single-source design deliberately removed the independent buffer — so only the node can keep
/// the last known value, which is what
/// <see cref="OpcUa.OpcUaPublishActor.AttributeStatusBatchUpdate"/> exists for. A Bad node still
/// carrying its last reading is OPC UA Part 8's convention and is what a SCADA consumer expects.
/// </para>
/// <para>
/// <b>Nothing is written on reconnect.</b> Publishing Good would assert a quality this actor has
/// not observed; recovery is the driver's own re-publish (see
/// <see cref="FanCommsLossToDependencyMux"/>'s remark on the detach that makes the poll engine
/// force-raise, and MQTT's initial push).
/// </para>
/// <para>
/// Ungated by redundancy role, like the condition annotation: a Secondary keeps its address
/// space honest for failover. Re-stamping an already-Bad node is harmless — the SDK's
/// <c>StatusValue</c> trigger dedups an unchanged status at the monitored item.
/// </para>
/// </remarks>
/// <param name="driverInstanceId">The driver child that just reported comms loss.</param>
private void FanCommsLossToValueNodes(string driverInstanceId)
{
if (_opcUaPublishActor is null) return;
if (!_valueRefsByDriver.TryGetValue(driverInstanceId, out var entries) || entries.Count == 0) return;
var ts = DateTime.UtcNow;
var writes = new List<StatusWrite>(entries.Count);
foreach (var (_, nodes) in entries)
{
foreach (var n in nodes)
{
writes.Add(new StatusWrite(n.NodeId, OpcUaStatus.BadNoCommunication, ts, n.Realm));
}
}
if (writes.Count == 0) return;
_opcUaPublishActor.Tell(new OpcUa.OpcUaPublishActor.AttributeStatusBatchUpdate(writes));
TrackCommsLatchSet(driverInstanceId, entries);
_log.Info(
"DriverHost {Node}: driver {Driver} disconnected — {Count} value node(s) marked BadNoCommunication (value retained)",
_localNode, driverInstanceId, writes.Count);
}
/// <summary>
/// Records every RawPath just latched <c>BadNoCommunication</c> for <paramref name="driverInstanceId"/>
/// (03/S15's recovery audit — see <see cref="_commsLatched"/>). Replaces any previous latch wholesale:
/// a second outage re-arms from the current ref set rather than accumulating refs a redeploy removed.
/// </summary>
/// <param name="driverInstanceId">The driver that just went comms-lost.</param>
/// <param name="entries">Its value refs, from the per-driver index.</param>
private void TrackCommsLatchSet(
string driverInstanceId,
List<(string RawPath, HashSet<NodeRealmRef> Nodes)> entries)
{
var latched = new HashSet<string>(entries.Count, StringComparer.Ordinal);
foreach (var (rawPath, _) in entries) latched.Add(rawPath);
_commsLatched[driverInstanceId] = latched;
// A previous reconnect's audit is now meaningless — this driver is down again.
Timers.Cancel(CommsAuditTimerKey(driverInstanceId));
}
/// <summary>
/// Clears <paramref name="fullReference"/> from <paramref name="driverInstanceId"/>'s latch, and drops
/// the driver's entry once it empties. Called per routed value item, so the steady-state cost with no
/// latch outstanding is one dictionary miss per burst — not per item.
/// </summary>
/// <param name="driverInstanceId">The publishing driver.</param>
/// <param name="fullReference">The RawPath that just published a value.</param>
private void TrackCommsLatchPrune(string driverInstanceId, string fullReference)
{
if (!_commsLatched.TryGetValue(driverInstanceId, out var latched)) return;
if (!latched.Remove(fullReference)) return;
if (latched.Count == 0) _commsLatched.Remove(driverInstanceId);
}
/// <summary>
/// Arms the recovery audit for a driver that has just reconnected: after
/// <see cref="_commsRecoveryAuditDelay"/>, <see cref="HandleCommsRecoveryAudit"/> reports whatever is
/// still latched. Nothing is armed when the driver has no latch — it never went Bad here.
/// </summary>
/// <param name="driverInstanceId">The driver that just reconnected.</param>
private void TrackCommsLatchArmAudit(string driverInstanceId)
{
if (!_commsLatched.ContainsKey(driverInstanceId)) return;
Timers.StartSingleTimer(
CommsAuditTimerKey(driverInstanceId),
new CommsRecoveryAudit(driverInstanceId),
_commsRecoveryAuditDelay);
}
private static string CommsAuditTimerKey(string driverInstanceId) => $"comms-audit:{driverInstanceId}";
/// <summary>
/// The 03/S15 tripwire firing: refs this host latched <c>BadNoCommunication</c> that the driver has
/// still not re-published <see cref="_commsRecoveryAuditDelay"/> after it reconnected.
/// </summary>
/// <remarks>
/// <para>
/// <b>It reports, it does not repair.</b> Writing Good here would assert a quality nothing has
/// observed — the very thing this whole path refuses to do. What the operator gets instead is a
/// named driver and a count, plus the <c>otopcua.driver.comms_latched_refs</c> counter, which is
/// the operator-visible signature of a driver that reconnects without re-publishing (the
/// pre-STAB-18 MQTT shape, or any future push driver that forgets its initial push).
/// </para>
/// <para>
/// The latch is cleared afterwards either way: it has been reported, and leaving it would make
/// the next outage's audit report refs from this one.
/// </para>
/// </remarks>
/// <param name="msg">Names the driver whose audit is due.</param>
private void HandleCommsRecoveryAudit(CommsRecoveryAudit msg)
{
if (!_commsLatched.TryGetValue(msg.DriverInstanceId, out var latched) || latched.Count == 0)
{
_commsLatched.Remove(msg.DriverInstanceId);
return;
}
var driverType = _children.TryGetValue(msg.DriverInstanceId, out var child) ? child.DriverType : "unknown";
OtOpcUaTelemetry.DriverCommsLatchedRefs.Add(
latched.Count,
new KeyValuePair<string, object?>("driver", msg.DriverInstanceId),
new KeyValuePair<string, object?>("driver_type", driverType));
_log.Warning(
"DriverHost {Node}: driver {Driver} ({Type}) — {Count} ref(s) still BadNoCommunication {Delay} after reconnect; "
+ "the driver did not re-publish them (missing initial push, or a value-only dedup swallowed an unchanged value). "
+ "First: {First}",
_localNode, msg.DriverInstanceId, driverType, latched.Count, _commsRecoveryAuditDelay,
latched.OrderBy(r => r, StringComparer.Ordinal).First());
_commsLatched.Remove(msg.DriverInstanceId);
}
/// <summary>
/// #481 (Layer 4) — push a synthetic <see cref="OpcUaStatus.BadNoCommunication"/> into the dependency
/// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
@@ -1778,6 +2008,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
Receive<CommsRecoveryAudit>(_ => { });
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -1423,6 +1423,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <inheritdoc />
protected override void PostStop()
{
// Archreview 03/S15 — announce comms loss on the way out. Every OTHER route into Reconnecting
// (watchdog trip, failed initial connect, Force Reconnect) announces it from the Reconnecting
// entry; stopping did not, so an operator's Restart and a supervision respawn tore this child down
// silently and its value nodes went on serving their last value at Good across the gap. The fresh
// child's re-subscribe + initial push (03/S16) is what clears them again; on a deploy that REMOVES
// the driver the host has already rebuilt its ref index without it, so the Tell finds nothing.
//
// Gated on having actually reached Connected: a child that never connected leaves its nodes at
// BadWaitingForInitialData, which says something more precise than "communication lost", and
// overwriting it would claim an outage that never happened.
if (_connectedSinceUtc != default)
{
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
}
DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
@@ -56,6 +56,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// their realm each (<see cref="ValueWrite"/>); status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchUpdate(IReadOnlyList<ValueWrite> Items);
/// <summary>Status-only batch: every named node keeps its CURRENT value and only its status +
/// timestamp change (archreview 03/S15). The comms-loss fan-out sends one of these covering every
/// NodeId a disconnected driver owns — the raw node and each referencing UNS node — so a client sees
/// <see cref="OpcUaStatus.BadNoCommunication"/> over the last known value instead of a stale Good.
/// <para>Deliberately a separate message from <see cref="AttributeValueBatchUpdate"/> rather than a
/// flag on it: the host holds no value cache (v3's single-source design), so it has nothing to put in
/// a <see cref="ValueWrite"/>, and only the node knows what value to retain.</para></summary>
public sealed record AttributeStatusBatchUpdate(IReadOnlyList<StatusWrite> Items);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
@@ -292,6 +301,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
Receive<AttributeStatusBatchUpdate>(HandleAttributeStatusBatchUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
@@ -368,6 +378,36 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>Routes a status-only batch to <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> — the
/// comms-loss annotation (03/S15). Same shape as <see cref="HandleAttributeBatchUpdate"/>: metered per
/// ITEM (batching is a transport change, so dashboards must not drop by the batch factor), a
/// <see cref="ValueWriteBatchException"/> is a partial failure that still counts what landed and names
/// the offender, and any other throw is swallowed so a sink fault cannot restart the publish actor and
/// drop live traffic. Metered under <c>kind:"status"</c> so a status annotation is separable from a
/// value write on the same counter.</summary>
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
{
try
{
_sink.WriteStatuses(msg.Items);
Interlocked.Add(ref _writes, msg.Items.Count);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "status"));
}
catch (ValueWriteBatchException ex)
{
_log.Warning(ex,
"OpcUaPublish: sink.WriteStatuses partially failed for a {Count}-item batch — {Failed} failed " +
"(first: {Node}), {Succeeded} applied",
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
Interlocked.Add(ref _writes, ex.SucceededCount);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "status"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteStatuses threw for a {Count}-item batch", msg.Items.Count);
}
}
private void HandleAlarmUpdate(AlarmStateUpdate msg)
{
try
@@ -66,10 +66,23 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
_log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count);
}
/// <summary>
/// Forwards one dependency value to the consumer, <b>status code included</b> (archreview
/// 05/STAB-19).
/// </summary>
/// <remarks>
/// This used to pass a hard-coded <c>0u</c> behind a comment saying quality was stripped at the
/// mux boundary. That was true when the adapter was written and stopped being true at #478/#519 —
/// <see cref="VirtualTagActor.DependencyValueChanged"/> has carried the source driver's full status
/// code, sub-code and all, ever since. The stale comment outliving the fact is what kept a
/// comms-lost input arriving at the Calculation driver as <i>arrived, Good, null</i>:
/// indistinguishable from a genuine Good null, so the warm-up gate was satisfied by a value that
/// never arrived and the calc output stayed Good over a dead source.
/// </remarks>
/// <param name="msg">The dependency value, as the mux delivered it.</param>
private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg)
{
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
_consumer.OnDependencyValue(msg.TagId, msg.Value, msg.StatusCode, msg.TimestampUtc);
}
/// <summary>The mux's batched delivery: one message carrying this adapter's whole matched slice of a
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -31,9 +32,13 @@ public sealed class VirtualTagActor : ReceiveActor
public const string ScriptLogsTopic = "script-logs";
/// <summary>A dependency's value changed. <paramref name="StatusCode"/> (#478) carries the source
/// driver's OPC UA status code so the scripted-alarm host can derive a condition's worst-of-input
/// quality; it defaults to <see cref="OpcUaStatus.Good"/>, and the virtual-tag engine ignores it.
/// Since #519 it carries the driver's sub-code, so a worst-of-input winner keeps the reason it won.</summary>
/// driver's OPC UA status code — since #519 including its sub-code, so a worst-of-input winner keeps
/// the reason it won — and it defaults to <see cref="OpcUaStatus.Good"/>.
/// <para><b>The virtual-tag engine consumes it</b> (archreview 02/S14). This doc used to say the
/// engine ignored it, which was true and became the defect: a Bad input was stored as a bare value,
/// the script ran against it, and the tag published a fresh-Good result over a dead source. The actor
/// now holds evaluation while any declared input is Bad and inherits the worst input's status
/// otherwise. The Calculation driver reads it through the same mux (05/STAB-19).</para></summary>
public sealed record DependencyValueChanged(
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
@@ -72,13 +77,22 @@ public sealed class VirtualTagActor : ReceiveActor
private readonly IActorRef? _mux;
private readonly ITelemetryLocalHub? _telemetryHub;
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);
// Dependency SNAPSHOTS, not bare values (archreview 02/S14): the status code is what distinguishes a
// comms-lost input from a genuine Good null, and it is what the script reads through
// ctx.GetTag(path).StatusCode.
private readonly Dictionary<string, DataValueSnapshot> _dependencies = new(StringComparer.Ordinal);
private bool _hasLastValue;
private object? _lastValue;
// 02/S13: true once a Bad EvaluationResult has been published and not yet cleared by a Good
// recovery. Gates Bad publishes to once-per-transition and force-publishes the recovery value.
private bool _lastPublishedBad;
/// <summary>Whether anything has been published for this tag yet — distinct from
/// <see cref="_hasLastValue"/>, because an inputs-Bad publish carries no value of its own.</summary>
private bool _hasPublished;
/// <summary>The status last published. Replaces 02/S13's two-state <c>_lastPublishedBad</c> flag
/// (02/S14): the dedup key is (value, status), a Bad may now carry the failing INPUT's sub-code rather
/// than a fixed BadInternalError, and a reassert re-emits this rather than guessing Good.</summary>
private uint _lastPublishedStatus = OpcUaStatus.Good;
/// <summary>Factory method to create Props for a VirtualTagActor.</summary>
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
@@ -178,15 +192,17 @@ public sealed class VirtualTagActor : ReceiveActor
/// </para></summary>
private void OnReassertValue()
{
if (!_hasLastValue && !_lastPublishedBad) return;
var statusCode = _lastPublishedBad ? OpcUaStatus.BadInternalError : OpcUaStatus.Good;
if (!_hasLastValue && !_hasPublished) return;
// The status LAST PUBLISHED, not a guess (02/S14). A VT whose input is comms-lost re-asserts
// BadNoCommunication onto the freshly-materialised node; re-asserting Good here would put the
// stale-Good lie back at every deploy.
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), statusCode, IsReassert: true));
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), _lastPublishedStatus, IsReassert: true));
}
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = msg.Value;
_dependencies[msg.TagId] = new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc);
Evaluate(msg.TimestampUtc);
}
@@ -210,7 +226,8 @@ public sealed class VirtualTagActor : ReceiveActor
for (var i = 0; i < count; i++)
{
var item = items[i];
_dependencies[item.TagId] = item.Value;
_dependencies[item.TagId] =
new DataValueSnapshot(item.Value, item.StatusCode, item.TimestampUtc, item.TimestampUtc);
}
Evaluate(items[count - 1].TimestampUtc);
@@ -222,6 +239,22 @@ public sealed class VirtualTagActor : ReceiveActor
/// <param name="timestampUtc">The triggering dependency change's timestamp, stamped on the result.</param>
private void Evaluate(DateTime timestampUtc)
{
// Worst-of-input FIRST (02/S14), before the evaluator is even considered.
//
// Over the deps that have ARRIVED, deliberately — a never-pushed dep is cold start, not a fault,
// and it is already handled by the inputs-ready gate in the two publish tails (which suppress the
// warm-up flash). Counting it as Bad here would instead HOLD evaluation during warm-up, which is
// a behaviour change this finding does not ask for: a partially-warmed-up VT evaluates today and
// must keep doing so, or every Good-path assertion in the existing suites changes meaning. Same
// skip ScriptedAlarmEngine.WorstInputStatus has always made.
var worst = InputQuality.WorstOf(
_dependencyRefs.Select(r => _dependencies.GetValueOrDefault(r)).Where(s => s is not null));
if (!InputQuality.IsUsable(worst))
{
OnInputsBad(worst, timestampUtc);
return;
}
VirtualTagEvalResult result;
try
{
@@ -249,9 +282,10 @@ public sealed class VirtualTagActor : ReceiveActor
return;
}
// Value dedup — but bypass it while recovering from Bad, so a recovery whose value equals the
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
if (!_lastPublishedBad && _hasLastValue && Equals(_lastValue, result.Value))
// Dedup on (value, STATUS) — 02/S14 generalising 02/S13's recover-from-Bad bypass. Keying on
// value alone swallows every recovery whose value is unchanged, which is what a device coming
// back usually produces, and the node then stays Bad forever.
if (_hasPublished && _lastPublishedStatus == worst && _hasLastValue && Equals(_lastValue, result.Value))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
return;
@@ -259,12 +293,55 @@ public sealed class VirtualTagActor : ReceiveActor
_hasLastValue = true;
_lastValue = result.Value;
_lastPublishedBad = false;
_hasPublished = true;
// The output inherits the worst input's quality: Good, or an Uncertain sub-code (Bad never
// reaches here — it was held above).
_lastPublishedStatus = worst;
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
var evalResult = new EvaluationResult(_virtualTagId, result.Value, timestampUtc, CorrelationId.NewId());
var evalResult = new EvaluationResult(_virtualTagId, result.Value, timestampUtc, CorrelationId.NewId(), worst);
Context.Parent.Tell(evalResult);
}
/// <summary>
/// 02/S14 — this tag's INPUTS are Bad, so the script is not run and the tag reports the worst
/// input's status, carrying its last known value.
/// </summary>
/// <remarks>
/// <para>
/// <b>Holding rather than evaluating-and-degrading</b> is what removes the
/// <c>(double)ctx.GetTag(x).Value</c> NRE storm at source: a comms-lost input used to arrive as
/// Good/null and the script ran against it on every trigger. It is also the alarm engine's
/// long-standing policy (<c>AreInputsReady</c>), so all three script seams now agree.
/// </para>
/// <para>
/// <b>No script-log entry</b>, deliberately: an input dying is not a script fault, and the
/// script log is 02/S13's per-failure SCRIPT diagnostic. Here the status code is the
/// diagnostic, and it names which failure the operator is looking at.
/// </para>
/// <para>
/// Published <b>once per change of the published status</b>, and only once every declared
/// dependency has arrived — R2-03's inputs-ready gate, kept as-is, so a cold start or an actor
/// respawn does not flash Bad while the mux is still filling the map.
/// </para>
/// </remarks>
/// <param name="worstInputStatus">The worst status among this tag's declared dependencies.</param>
/// <param name="timestampUtc">The triggering change's timestamp, stamped on the result.</param>
private void OnInputsBad(uint worstInputStatus, DateTime timestampUtc)
{
if (!AllDependenciesArrived()) return;
if (_hasPublished && _lastPublishedStatus == worstInputStatus) return;
// Metered BELOW both guards, so the counter measures tags that actually REPORT Bad inputs. Above
// them it also counted every warm-up trigger — a dependency that has simply not arrived yet — which
// is ordinary cold start, and burying the real signal in it is how a counter stops being trusted.
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "inputs-bad"));
_hasPublished = true;
_lastPublishedStatus = worstInputStatus;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), worstInputStatus));
}
/// <summary>02/S13 failure tail shared by the evaluator-threw catch and the <c>!Success</c> branch.
/// Always meters + publishes the per-failure script-log entry (the pre-existing operator
/// diagnostic); additionally publishes a Bad <see cref="EvaluationResult"/> — carrying the
@@ -278,12 +355,13 @@ public sealed class VirtualTagActor : ReceiveActor
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog(level, reason);
if (_lastPublishedBad || !AllDependenciesArrived())
if ((_hasPublished && _lastPublishedStatus == OpcUaStatus.BadInternalError) || !AllDependenciesArrived())
{
return;
}
_lastPublishedBad = true;
_hasPublished = true;
_lastPublishedStatus = OpcUaStatus.BadInternalError;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
}
@@ -0,0 +1,97 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Truth table for <see cref="InputQuality"/> — the one worst-of-input rule every derived-value
/// seam uses (scripted alarms, virtual tags, the Calculation driver). The quality contract
/// (archreview R3-02) is "a value consumer never reports a quality better than its source's",
/// and this type is the single place that decides what "worse" means, so a divergence here is a
/// divergence in every seam at once.
/// </summary>
public sealed class InputQualityTests
{
private const uint Good = 0x00000000u;
private const uint Uncertain = 0x40000000u;
private const uint UncertainLastUsableValue = 0x40900000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadInternalError = 0x80020000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private static DataValueSnapshot Snap(object? value, uint status) =>
new(value, status, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
/// <summary>An empty input set reports Good — there is nothing to be worse than.</summary>
[Fact]
public void WorstOf_no_inputs_is_Good()
{
InputQuality.WorstOf([]).ShouldBe(Good);
}
/// <summary>All-Good inputs report Good.</summary>
[Fact]
public void WorstOf_all_good_is_Good()
{
InputQuality.WorstOf([Snap(1, Good), Snap(2, Good)]).ShouldBe(Good);
}
/// <summary>A single Uncertain input carries its own sub-code out, not the generic Uncertain.</summary>
[Fact]
public void WorstOf_propagates_the_uncertain_sub_code()
{
InputQuality.WorstOf([Snap(1, Good), Snap(2, UncertainLastUsableValue)])
.ShouldBe(UncertainLastUsableValue);
}
/// <summary>Bad beats Uncertain regardless of the order the inputs are seen in.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void WorstOf_bad_beats_uncertain(bool badFirst)
{
DataValueSnapshot?[] inputs = badFirst
? [Snap(null, BadNoCommunication), Snap(2, Uncertain)]
: [Snap(2, Uncertain), Snap(null, BadNoCommunication)];
InputQuality.WorstOf(inputs).ShouldBe(BadNoCommunication);
}
/// <summary>
/// A null entry means "this dependency has never been pushed", which is
/// <c>BadWaitingForInitialData</c> — the same code the materialiser stamps on a fresh node.
/// Reporting Good for it would be the exact laundering this helper exists to stop.
/// </summary>
[Fact]
public void WorstOf_a_null_entry_is_BadWaitingForInitialData()
{
InputQuality.WorstOf([Snap(1, Good), null]).ShouldBe(BadWaitingForInitialData);
}
/// <summary>
/// Among equal severity the FIRST seen wins, so a re-evaluation over the same inputs reports
/// the same sub-code rather than oscillating between two equally-bad codes.
/// </summary>
[Fact]
public void WorstOf_equal_severity_keeps_the_first_seen()
{
InputQuality.WorstOf([Snap(null, BadNoCommunication), Snap(null, BadInternalError)])
.ShouldBe(BadNoCommunication);
InputQuality.WorstOf([Snap(null, BadInternalError), Snap(null, BadNoCommunication)])
.ShouldBe(BadInternalError);
}
/// <summary>Good and Uncertain are usable (a script may still inspect the value); Bad is not.</summary>
[Theory]
[InlineData(Good, true)]
[InlineData(Uncertain, true)]
[InlineData(UncertainLastUsableValue, true)]
[InlineData(BadNoCommunication, false)]
[InlineData(BadWaitingForInitialData, false)]
[InlineData(0xC0000000u, false)]
public void IsUsable_follows_Part4_severity(uint statusCode, bool expected)
{
InputQuality.IsUsable(statusCode).ShouldBe(expected);
}
}
@@ -0,0 +1,316 @@
using System.Collections.Concurrent;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>
/// Archreview <b>05/STAB-19</b> — what the Calculation driver does with a dependency's <b>quality</b>.
///
/// <para><b>The defect.</b> The driver stored dependency <i>values</i> only and ignored the status code
/// entirely, so a comms-lost input arrived as <em>arrived, Good, null</em>: the warm-up gate was
/// satisfied by a value that never came, <c>(double)ctx.GetTag("x").Value</c> threw on every trigger
/// (a "script threw" storm), and a null-tolerant script published a <b>Good</b> output computed from a
/// dead input — indefinitely.</para>
///
/// <para><b>The policy pinned here</b> matches <c>ScriptedAlarmEngine</c>'s, which is the in-tree
/// precedent: <b>hold on Bad, propagate the worst input's status; evaluate through Uncertain and stamp
/// the output with it.</b> Holding (rather than evaluating and degrading) is what removes the NRE
/// storm; a Bad sub-code (rather than Uncertain) is what tells the operator the value may be
/// arbitrarily old rather than merely imprecise.</para>
///
/// <para>⚠️ <b>Vacuity rule:</b> the recovery case re-publishes an <b>identical</b> computed value.
/// A recovery whose value differs passes against a value-only dedup and proves nothing — it is the
/// "Bad forever" trap the v3 Batch 4 live gate found.</para>
/// </summary>
public sealed class CalculationDriverInputQualityTests
{
private const uint StatusGood = 0u;
private const uint StatusUncertain = 0x40000000u;
private const uint StatusBadNoCommunication = 0x80310000u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private const string SumTag = "calc/e/sum";
private const string SumScript = "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;";
private static RawTagEntry CalcTag(string rawPath, string source)
{
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":true," +
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
Build(params RawTagEntry[] tags)
{
var logs = new List<ScriptLogEntry>();
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
var published = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => published.Enqueue(e);
return (driver, published, logs);
}
/// <summary>Local copy of the script-log capture sink (the sibling suite's is private to it).</summary>
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
{
private readonly object _lock = new();
public void Publish(ScriptLogEntry entry)
{
lock (_lock) sink.Add(entry);
}
}
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (driver.IsEvaluationIdle) return;
await Task.Delay(10);
}
driver.IsEvaluationIdle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
}
/// <summary>Drives both deps Good and drains the resulting publish, leaving a settled Good output.</summary>
private static async Task<CalculationDriver> WarmedUpAsync(ConcurrentQueue<DataChangeEventArgs> published, CalculationDriver driver)
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var first).ShouldBeTrue();
first!.Snapshot.Value.ShouldBe(30.0);
first.Snapshot.StatusCode.ShouldBe(StatusGood);
return driver;
}
/// <summary>
/// A Bad input holds the script and publishes the input's own sub-code, once, carrying the last
/// known value. Three things are asserted together because each is a separate way the old
/// behaviour was wrong: no evaluation (no NRE storm), the sub-code propagated (not a generic
/// <c>BadInternalError</c>, which would blame the script), and no script-log entry (an input
/// dying is not a script fault).
/// </summary>
[Fact]
public async Task A_bad_input_holds_evaluation_and_publishes_the_inputs_own_sub_code_once()
{
var (driver, published, logs) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.StatusCode.ShouldNotBe(StatusBadInternalError, "the script did not fail — its input did");
bad.Snapshot.Value.ShouldBe(30.0, "the last known value rides out with the Bad status");
logs.ShouldBeEmpty("a dead input is not a script fault; the script log stays the per-failure diagnostic");
// Transition-once: a second Bad push of the same code changes nothing observable.
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty();
}
/// <summary>
/// A different Bad sub-code IS republished: the sub-code is the diagnostic, and masking
/// <c>BadNoCommunication</c> behind an earlier <c>BadWaitingForInitialData</c> (or vice versa)
/// hides which input died and why.
/// </summary>
[Fact]
public async Task A_different_bad_sub_code_is_republished()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out _).ShouldBeTrue();
driver.OnDependencyValue("src/a", null, StatusBadInternalError, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var second).ShouldBeTrue();
second!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
}
/// <summary>
/// <b>The vacuity-sensitive assertion.</b> The input recovers with the value it had before the
/// outage, so the computed output is <b>identical</b> to the pre-outage one — and it must still be
/// republished Good, because the node is currently Bad and only a publish can clear it. A
/// value-only dedup swallows exactly this case, which is why a recovery test with a changed value
/// is not the guard.
/// </summary>
[Fact]
public async Task An_identical_value_is_republished_when_recovering_from_a_bad_input()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out _).ShouldBeTrue();
// The device comes back holding the same reading it had before.
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var recovered).ShouldBeTrue();
recovered!.Snapshot.Value.ShouldBe(30.0, "the same value as before the outage — that is the point");
recovered.Snapshot.StatusCode.ShouldBe(StatusGood);
}
/// <summary>
/// An Uncertain input is still computable, so the script runs and the output inherits the
/// Uncertain code — the alarm engine's rule (Uncertain is "ready"). Holding here would take a
/// perfectly good arithmetic result hostage.
/// </summary>
[Fact]
public async Task An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", 11.0, StatusUncertain, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var uncertain).ShouldBeTrue();
uncertain!.Snapshot.Value.ShouldBe(31.0, "the script DID run — Uncertain is usable");
uncertain.Snapshot.StatusCode.ShouldBe(StatusUncertain);
// …and returning to Good republishes, even though the computed value changes back to a value the
// tag has held before.
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.StatusCode.ShouldBe(StatusGood);
good.Snapshot.Value.ShouldBe(30.0);
}
/// <summary>
/// Cold start is unchanged: with one dep never pushed, a Bad arrival on the other publishes
/// nothing at all (the node stays at the materialiser's <c>BadWaitingForInitialData</c>). Only once
/// every dep has arrived does a Bad among them become reportable.
/// </summary>
[Fact]
public async Task Nothing_is_published_while_a_dep_has_never_arrived()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty("src/b has never arrived — this is warm-up, not a fault");
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
}
/// <summary>
/// A passthrough script over a Bad input must not publish <c>Ok(null)</c> Good — the worst case of
/// the laundering, since the passthrough fast path bypasses the read cache entirely.
/// </summary>
[Fact]
public async Task A_passthrough_over_a_bad_input_is_bad_not_good_null()
{
var (driver, published, _) = Build(CalcTag("calc/e/pass", "return ctx.GetTag(\"src/a\").Value;"));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 42.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.Value.ShouldBe(42.0);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.StatusCode.ShouldNotBe(StatusGood);
}
/// <summary>
/// <c>ReadAsync</c> reports what the subscriber was told. A read that answered Good while the
/// subscription carried Bad would leave two clients of the same node disagreeing about it.
/// </summary>
[Fact]
public async Task ReadAsync_reports_the_same_status_the_subscriber_saw()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await WarmedUpAsync(published, driver);
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var bad).ShouldBeTrue();
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(bad!.Snapshot.StatusCode);
read[0].Value.ShouldBe(bad.Snapshot.Value);
}
/// <summary>
/// A tag whose ONLY publish was inputs-Bad — it never produced a value — must read back that Bad
/// status, not <c>BadWaitingForInitialData</c>. Guarding the read on "has a value" rather than
/// "has published" made a polled read say "never polled" while the subscriber on the same node had
/// been told <c>BadNoCommunication</c>: the two clients disagree, and the read is the one that
/// lies (something HAS been observed about this tag — that its input is dead).
/// </summary>
[Fact]
public async Task ReadAsync_after_an_inputs_Bad_with_no_prior_value_reports_the_bad_status()
{
var (driver, published, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
// The first thing that ever happens to this tag is a dead input — no successful evaluation, so
// there is no last value to carry.
driver.OnDependencyValue("src/a", null, StatusBadNoCommunication, DateTime.UtcNow);
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadNoCommunication);
bad.Snapshot.Value.ShouldBeNull();
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(StatusBadNoCommunication);
read[0].StatusCode.ShouldNotBe(StatusBadWaitingForInitialData,
"something HAS been observed about this tag — that its input is dead");
read[0].Value.ShouldBeNull();
}
/// <summary>A never-evaluated tag still reads BadWaitingForInitialData (regression).</summary>
[Fact]
public async Task An_unevaluated_tag_still_reads_BadWaitingForInitialData()
{
var (driver, _, _) = Build(CalcTag(SumTag, SumScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var read = await driver.ReadAsync([SumTag], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
@@ -230,6 +231,74 @@ public sealed class CalculationEvaluatorTests
return (int)countProp.GetValue(cache)!;
}
// ── input quality reaches the script (archreview 05/STAB-19) ────────────────────────────
/// <summary>
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the input's <b>real</b> status.
/// The read cache used to stamp every dependency Good, so a script that checked quality was lied
/// to — and, since the driver now holds on Bad, an Uncertain input is precisely the case where a
/// script can still make a qualified decision and needs the truth to do it.
/// </summary>
[Fact]
public void The_read_cache_carries_the_inputs_real_status_code()
{
const uint uncertain = 0x40000000u;
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
var sourceStamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
calcTagId: "calc-quality",
scriptSource: "return (long)ctx.GetTag(\"a\").StatusCode;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7.0, uncertain, sourceStamp, sourceStamp),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe((long)uncertain);
}
/// <summary>
/// The dependency's own source timestamp reaches the script too. The old cache stamped
/// evaluation time on every input, so a script comparing input ages compared its own clock with
/// itself.
/// </summary>
[Fact]
public void The_read_cache_carries_the_inputs_real_source_timestamp()
{
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
var sourceStamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
calcTagId: "calc-stamp",
scriptSource: "return ctx.GetTag(\"a\").SourceTimestampUtc;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7.0, 0u, sourceStamp, DateTime.UtcNow),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(sourceStamp);
}
/// <summary>The passthrough fast path takes the same snapshot input and returns its value.</summary>
[Fact]
public void The_passthrough_fast_path_accepts_snapshots()
{
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
calcTagId: "calc-mirror",
scriptSource: "return ctx.GetTag(\"a\").Value;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(42, 0u, DateTime.UtcNow, DateTime.UtcNow),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
/// <summary>Minimal <see cref="ILogger{T}"/> that records emitted entries so the SetVirtualTag-drop
/// test can assert the Debug "dropped" line was logged.</summary>
private sealed class CapturingLogger : ILogger<CalculationEvaluator>
@@ -615,6 +615,81 @@ public sealed class MqttConnectionTests
blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt);
}
// ---------------------------------------------------------------------------------
// ConnectionLost — the quality edge (archreview 05/STAB-18 shape a)
// ---------------------------------------------------------------------------------
/// <summary>
/// <see cref="MqttConnection.ConnectionLost"/> fires exactly once per
/// <c>Connected → Reconnecting</c> edge. It is the signal the ingest paths stale their cached
/// values on, so an extra fire is downstream churn and a missing one leaves every tag
/// reporting Good over a dead session.
/// </summary>
[Fact]
public async Task BrokerDropsTheConnection_FiresConnectionLostOncePerEdge()
{
using var broker = new MiniBroker();
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await conn.ConnectAsync(CancellationToken.None);
Volatile.Read(ref losses).ShouldBe(0, "a successful connect is not a loss");
broker.DropAll();
(await WaitUntilAsync(() => Volatile.Read(ref losses) >= 1, TimeSpan.FromSeconds(30))).ShouldBeTrue();
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(30)))
.ShouldBeTrue();
Volatile.Read(ref losses).ShouldBe(1, "the reconnect itself is not a second loss");
broker.DropAll();
(await WaitUntilAsync(() => Volatile.Read(ref losses) >= 2, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("a second outage is a second edge");
}
/// <summary>
/// An initial connect that never succeeds is not a <i>loss</i> — there was no session to lose,
/// and staling on it would flip a never-observed tag Bad on a shape the cache already reports
/// as <c>BadWaitingForInitialData</c>.
/// </summary>
[Fact]
public async Task ConnectionLost_DoesNotFireOnAnInitialConnectFailure()
{
using var blackhole = new BlackholeBroker();
var opts = new MqttDriverOptions
{
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
};
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
Volatile.Read(ref losses).ShouldBe(0);
}
/// <summary>
/// Dispose tears the client down, which the library reports as a disconnect. That is an
/// intentional teardown, not an outage: firing here would stale every tag on an ordinary
/// redeploy or shutdown.
/// </summary>
[Fact]
public async Task ConnectionLost_DoesNotFireOnDispose()
{
using var broker = new MiniBroker();
var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
var losses = 0;
conn.ConnectionLost += () => Interlocked.Increment(ref losses);
await conn.ConnectAsync(CancellationToken.None);
await conn.DisposeAsync();
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
Volatile.Read(ref losses).ShouldBe(0);
}
// ---------------------------------------------------------------------------------
// Task 4 — the reconnect supervisor, against a real (minimal) broker
// ---------------------------------------------------------------------------------
@@ -100,6 +100,79 @@ public sealed class MqttDriverSessionLifecycleTests
"the authored 2s budget must still bound the connect, not the 15s default");
}
/// <summary>
/// <b>End-to-end through <see cref="MqttDriver"/> (archreview 05/STAB-18).</b> Deliver a value,
/// let the real broker drop the real session, and the driver publishes
/// <c>BadNoCommunication</c> with the value retained; feed the <b>identical</b> payload back —
/// the retained replay a broker sends on reconnect — and it publishes Good again.
/// </summary>
/// <remarks>
/// <para>
/// <b>What this adds over <c>MqttSubscriptionManagerRecoveryTests</c>:</b> the wiring. Those
/// drive <c>OnConnectionLost()</c> directly, so they stay green even if
/// <c>MqttSubscriptionManager.AttachTo</c> never subscribes to
/// <see cref="MqttConnection.ConnectionLost"/> — an unforwarded seam is this repo's
/// best-documented way to ship an inert fix. Here the edge comes from a genuine socket drop
/// through MQTTnet's own <c>DisconnectedAsync</c>.
/// </para>
/// <para>
/// <b>Vacuity rule:</b> the recovery payload is byte-identical to the pre-outage one. A test
/// that replays a <i>different</i> value passes against a build with no stale at all — the
/// value change alone would defeat the suppression — and is not the guard.
/// </para>
/// <para>
/// Ingest is fed through <c>Subscriptions.HandleMessage</c>, the method MQTTnet's dispatcher
/// calls, because <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else: it
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
/// </para>
/// </remarks>
[Fact]
public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers()
{
const string rawPath = "Plant/Mqtt/dev1/Temp";
const string topic = "factory/oven/temp";
const uint good = 0x00000000u;
const uint badNoCommunication = 0x80310000u;
using var broker = new MiniBroker();
var options = BrokerOptions(broker.Port) with
{
RawTags =
[
new RawTagEntry(
rawPath,
$$"""{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""",
WriteIdempotent: false),
],
};
await using var driver = new MqttDriver(options, "d", null);
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
await driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
var raised = new List<DataValueSnapshot>();
driver.OnDataChange += (_, e) => { lock (raised) raised.Add(e.Snapshot); };
driver.Subscriptions.HandleMessage(topic, """{"value":21.5}"""u8.ToArray(), retained: false);
lock (raised) raised.Count.ShouldBe(1, "the first value must publish");
broker.DropAll();
await WaitForAsync(
() => { lock (raised) return raised.Count == 2; },
"the broker drop to publish a BadNoCommunication snapshot");
DataValueSnapshot staled;
lock (raised) staled = raised[1];
staled.StatusCode.ShouldBe(badNoCommunication);
staled.Value.ShouldBe(21.5, "the last known value is retained through the outage");
// The reconnect's retained replay — the IDENTICAL payload.
driver.Subscriptions.HandleMessage(topic, """{"value":21.5}"""u8.ToArray(), retained: true);
DataValueSnapshot recovered;
lock (raised) { raised.Count.ShouldBe(3); recovered = raised[2]; }
recovered.StatusCode.ShouldBe(good);
recovered.Value.ShouldBe(21.5);
}
private static async Task WaitForAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
@@ -0,0 +1,229 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The push-model edge behaviours <see cref="MqttSubscriptionManager"/> lacked (archreview
/// 05/STAB-18): an <b>initial push</b> of the cached snapshot on every (re)subscribe, and a
/// <b>connection-loss stale</b> to <c>BadNoCommunication</c> with the value retained.
///
/// <para><b>Why both, and why the identical payload is load-bearing.</b> The manager's steady-state
/// dedup suppresses a message whose value AND status equal the cached one, and that cache is
/// driver-owned — it survives a reconnect and a redeploy. So a broker that replays its retained
/// message unchanged after an outage raised nothing, and every node the host had latched Bad stayed
/// Bad until the plant value happened to change. A recovery test that replays a <em>different</em>
/// payload passes against the pre-fix code and proves nothing; every recovery assertion here
/// therefore replays the <b>identical</b> payload.</para>
///
/// <para>The steady-state suppression itself must survive — it is what keeps a 10 Hz topic from
/// re-triggering the whole downstream fan-out per message — so each recovery case is paired with a
/// control that asserts an unchanged republish is still suppressed.</para>
/// </summary>
public sealed class MqttSubscriptionManagerRecoveryTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const string TempPath = "Plant/Mqtt/broker1/OvenTemp";
private const string PressPath = "Plant/Mqtt/broker1/OvenPressure";
private const string TempTopic = "f/oven/temp";
private const string PressTopic = "f/oven/press";
private static RawTagEntry Tag(string rawPath, string topic) =>
new(rawPath, $$"""{"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""",
WriteIdempotent: false);
private static MqttSubscriptionManager NewManager()
{
var mgr = new MqttSubscriptionManager(new MqttDriverOptions { Mode = MqttMode.Plain }, "mqtt-1");
mgr.Register([Tag(TempPath, TempTopic), Tag(PressPath, PressTopic)]);
return mgr;
}
private sealed record Raised(string Reference, object? Value, uint StatusCode, ISubscriptionHandle Handle);
private static List<Raised> Record(MqttSubscriptionManager mgr)
{
var raised = new List<Raised>();
mgr.OnDataChange += (_, e) =>
raised.Add(new Raised(e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode, e.SubscriptionHandle));
return raised;
}
// ---------------------------------------------------------------------------------
// (A) Initial push on (re)subscribe — 05/STAB-18 shape (b)
// ---------------------------------------------------------------------------------
/// <summary>
/// A ref observed before the subscription existed is pushed once, with its cached snapshot,
/// attributed to the new handle. This is the push-model equivalent of the poll engine's
/// <c>forceRaise: true</c> on the first poll of a new subscription, and it is what refills a
/// node the address-space rebuild reseeded <c>BadWaitingForInitialData</c> on a quiet topic.
/// </summary>
[Fact]
public async Task SubscribeAsync_pushes_the_cached_snapshot_for_an_observed_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false); // cached; no handle yet
var raised = Record(mgr);
var handle = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Count.ShouldBe(1);
raised[0].Reference.ShouldBe(TempPath);
raised[0].Value.ShouldBe(21.5);
raised[0].StatusCode.ShouldBe(Good);
raised[0].Handle.ShouldBe(handle);
}
/// <summary>
/// A ref that has never been observed carries only the materialiser's
/// <c>BadWaitingForInitialData</c> placeholder — pushing that would claim an observation the
/// driver has not made, so the initial push skips it.
/// </summary>
[Fact]
public async Task SubscribeAsync_does_not_push_a_never_observed_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var raised = Record(mgr);
await mgr.SubscribeAsync([TempPath, PressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Select(r => r.Reference).ShouldBe([TempPath]);
mgr.Values.Read(PressPath).StatusCode.ShouldBe(BadWaitingForInitialData);
}
/// <summary>
/// <b>Control.</b> The initial push must not weaken steady-state suppression: an identical
/// message arriving after the subscribe is still swallowed. Without this, "recovery works"
/// could simply mean "nothing is ever deduped any more".
/// </summary>
[Fact]
public async Task An_identical_message_after_the_initial_push_is_still_suppressed()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var raised = Record(mgr);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
raised.Count.ShouldBe(1); // the initial push only
}
/// <summary>
/// The rebuild shape: unsubscribe and subscribe again — as every deploy does through
/// <c>DriverInstanceActor</c>'s unsubscribe-then-subscribe — and the cached value is pushed
/// again onto the new handle rather than leaving the rebuilt node waiting for the plant.
/// </summary>
[Fact]
public async Task A_resubscribe_pushes_again()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
var first = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await mgr.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
var raised = Record(mgr);
var second = await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
raised.Count.ShouldBe(1);
raised[0].Value.ShouldBe(21.5);
raised[0].Handle.ShouldBe(second);
}
// ---------------------------------------------------------------------------------
// (B) Connection-loss stale — 05/STAB-18 shape (a)
// ---------------------------------------------------------------------------------
/// <summary>
/// A lost broker session makes every observed reference <c>BadNoCommunication</c> — with its
/// last value retained, the Part 8 last-known-value convention a SCADA client expects. MQTT
/// previously marked nothing at all on its own disconnect edge, so the tags stayed Good until
/// the host's 90 s comms-loss watchdog noticed (if it noticed at all — MQTTnet usually
/// reconnects well inside that window).
/// </summary>
[Fact]
public async Task OnConnectionLost_stales_every_observed_ref_keeping_its_value()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath, PressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var raised = Record(mgr); // recorded AFTER the subscribe, so the initial push is not counted
mgr.OnConnectionLost();
raised.Count.ShouldBe(1, "the never-observed ref has nothing to stale");
raised[0].Reference.ShouldBe(TempPath);
raised[0].StatusCode.ShouldBe(BadNoCommunication);
raised[0].Value.ShouldBe(21.5, "the last known value is retained, not nulled");
mgr.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication);
mgr.Values.Read(TempPath).Value.ShouldBe(21.5);
}
/// <summary>
/// <b>The contract test for this seam.</b> After the stale, the broker's retained replay of the
/// <b>identical</b> payload is raised Good — because the cached status now differs even though
/// the value does not. Replaying a <em>different</em> payload here would pass against the
/// pre-fix build and prove nothing; the identical payload is the whole point.
/// </summary>
[Fact]
public async Task After_the_stale_the_identical_retained_replay_is_raised_Good()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.OnConnectionLost();
var raised = Record(mgr);
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: true); // the retained seed
raised.Count.ShouldBe(1);
raised[0].Reference.ShouldBe(TempPath);
raised[0].Value.ShouldBe(21.5);
raised[0].StatusCode.ShouldBe(Good);
}
/// <summary>
/// A second loss with no intervening message raises nothing: the ref is already Bad, and a
/// re-stamp would be pure churn on the downstream fan-out.
/// </summary>
[Fact]
public async Task OnConnectionLost_does_not_re_raise_an_already_bad_ref()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
mgr.OnConnectionLost();
var raised = Record(mgr);
mgr.OnConnectionLost();
raised.ShouldBeEmpty();
}
/// <summary>
/// The stale runs on MQTTnet's dispatcher thread, so it must complete synchronously and never
/// await: parking that thread stalls delivery for every other subscription on the connection.
/// </summary>
[Fact]
public async Task OnConnectionLost_completes_on_the_calling_thread()
{
var mgr = NewManager();
mgr.HandleMessage(TempTopic, """{"value":21.5}"""u8.ToArray(), retained: false);
await mgr.SubscribeAsync([TempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
var callingThread = Environment.CurrentManagedThreadId;
var raisedOn = 0;
mgr.OnDataChange += (_, _) => raisedOn = Environment.CurrentManagedThreadId;
mgr.OnConnectionLost();
raisedOn.ShouldBe(callingThread);
}
}
@@ -0,0 +1,268 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// The two push-model edge behaviours <see cref="SparkplugIngestor"/> lacked (archreview
/// 05/STAB-18), matching <see cref="MqttSubscriptionManagerRecoveryTests"/> on the plain path: an
/// <b>initial push</b> of the cached snapshot on every (re)subscribe, and a
/// <b>connection-loss stale</b> to <c>BadNoCommunication</c> with the value retained.
///
/// <para><b>Where the falsifier actually is on this seam — measured, not assumed.</b> Recovery is
/// proved with an <b>identical</b> DBIRTH value, as on the plain path. But Sparkplug ingestion
/// carries <em>no</em> unchanged-value suppression (it publishes births and data directly rather
/// than through <c>MqttSubscriptionManager.Deliver</c>), so that replay was already raised Good
/// before this change — pinned below by
/// <see cref="An_identical_birth_replay_is_raised_even_without_a_preceding_stale"/>. The
/// load-bearing assertion here is therefore the <b>stale</b>: a Sparkplug tag now reports Bad on
/// the driver's own disconnect edge instead of Good over a dead broker.</para>
///
/// <para><b>A death is not a connection loss.</b> DDEATH/NDEATH is the source announcing it is
/// offline and keeps its own <c>BadCommunicationError</c> stale, unchanged; <c>ConnectionLost</c>
/// is this driver's transport losing the broker and reports <c>BadNoCommunication</c> — the same
/// code the host's comms-loss fan-out writes. The DDEATH regression case below pins that the two
/// paths stayed distinct.</para>
/// </summary>
public sealed class SparkplugIngestorRecoveryTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private const string Group = "Plant1";
private const string Node = "EdgeA";
private const string Device = "Filler1";
private const string TempPath = "Plant/Mqtt/spb/Filler1Temp";
private const string PressPath = "Plant/Mqtt/spb/Filler1Press";
private const ulong TempAlias = 5UL;
private const ulong PressAlias = 6UL;
private sealed record Raised(string Reference, object? Value, uint StatusCode);
private static List<Raised> Record(SparkplugIngestor ing)
{
var raised = new List<Raised>();
ing.OnDataChange += (_, e) => raised.Add(new Raised(e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode));
return raised;
}
// ---------------------------------------------------------------------------------
// (A) Initial push on (re)subscribe
// ---------------------------------------------------------------------------------
/// <summary>
/// A metric observed before this subscription existed is pushed once from the cache. This is the
/// address-space-rebuild shape: a rebuild reseeds every node <c>BadWaitingForInitialData</c> and
/// triggers no rebirth, so without the push a quiet metric never refills.
/// </summary>
[Fact]
public async Task SubscribeAsync_pushes_the_cached_snapshot_for_an_observed_metric()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7f)));
var raised = Record(ing);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
raised.Count.ShouldBe(1);
raised[0].Reference.ShouldBe(TempPath);
raised[0].Value.ShouldBe(7f);
raised[0].StatusCode.ShouldBe(Good);
}
/// <summary>
/// A metric that has never been observed carries only the cache's
/// <c>BadWaitingForInitialData</c> placeholder, and pushing that would claim an observation the
/// driver has not made.
/// </summary>
[Fact]
public async Task SubscribeAsync_does_not_push_a_never_observed_metric()
{
var ing = await NewSubscribedIngestorAsync();
var raised = Record(ing);
await ing.SubscribeAsync([TempPath, PressPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
raised.ShouldBeEmpty();
ing.Values.Read(TempPath).StatusCode.ShouldBe(BadWaitingForInitialData);
}
// ---------------------------------------------------------------------------------
// (B) Connection-loss stale + the identical-value recovery
// ---------------------------------------------------------------------------------
/// <summary>
/// A lost broker session marks every observed metric <c>BadNoCommunication</c> with its last
/// value retained. <c>ResetSessionState</c>'s previous remark argued the opposite — that the
/// connection's health surface already reports the outage — but health is not quality, and an
/// OPC UA client subscribed to the metric's node sees neither the health page nor the outage.
/// </summary>
[Fact]
public async Task OnConnectionLost_stales_every_observed_metric_keeping_its_value()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7f)));
var raised = Record(ing);
ing.OnConnectionLost();
raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == BadNoCommunication && Equals(r.Value, 7f));
ing.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication);
ing.Values.Read(TempPath).Value.ShouldBe(7f);
}
/// <summary>
/// <b>The contract test for this seam.</b> The reconnect replays NBIRTH/DBIRTH carrying the
/// <b>identical</b> metric value, and it must be raised Good. Before the stale, the birth's
/// value equalled the cached one and the whole tag stayed Bad until the plant moved.
/// </summary>
[Fact]
public async Task After_the_stale_an_identical_birth_replay_is_raised_Good()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing, tempBirthValue: 7f);
ing.OnConnectionLost();
ing.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication);
var raised = Record(ing);
FeedBirths(ing, tempBirthValue: 7f, bdSeq: 2); // the reconnect's replayed births, value unchanged
raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == Good && Equals(r.Value, 7f));
}
/// <summary>
/// <b>Vacuity control, and it changes what the test above proves.</b> Unlike the plain path,
/// Sparkplug ingestion has no unchanged-value suppression at all — a birth republishes its
/// metrics regardless — so an identical birth replay would have been raised Good even before
/// the stale existed. This asserts that directly. The consequence: on THIS seam the
/// load-bearing assertion is the stale itself (a Sparkplug tag now reports Bad on the driver's
/// own disconnect edge instead of Good over a dead broker), and the replay test guards only
/// that the stale is not sticky.
/// </summary>
[Fact]
public async Task An_identical_birth_replay_is_raised_even_without_a_preceding_stale()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing, tempBirthValue: 7f);
var raised = Record(ing);
FeedBirths(ing, tempBirthValue: 7f, bdSeq: 2);
raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == Good && Equals(r.Value, 7f));
}
/// <summary>
/// Regression: a DDEATH still stales with <c>BadCommunicationError</c>, the source-announced
/// code. The connection-loss stale is a second, distinct path — it did not replace this one.
/// </summary>
[Fact]
public async Task DDeath_still_stales_with_BadCommunicationError()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing, tempBirthValue: 7f);
var raised = Record(ing);
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == BadCommunicationError);
ing.Values.Read(TempPath).StatusCode.ShouldBe(BadCommunicationError);
}
// =================================================================================
// Fixtures — deliberately a local copy of SparkplugIngestorTests' helpers (both are
// private to their own class; sharing them would make one suite's edits ripple into
// the other's fixtures).
// =================================================================================
private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
private static string Blob(string group, string node, string? device, string metric, string dataType)
{
var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
return $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}",{{deviceKey}}"metricName":"{{metric}}","dataType":"{{dataType}}"}""";
}
private static async Task<SparkplugIngestor> NewSubscribedIngestorAsync()
{
var options = new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = Group, HostId = "otopcua-host-1" },
};
var ingestor = new SparkplugIngestor(options, "mqtt-spb-1", rebirthDebounce: TimeSpan.Zero);
ingestor.Register(
[
Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
Tag(PressPath, Blob(Group, Node, Device, "Pressure", "Float32")),
]);
await ingestor.SubscribeAsync([.. ingestor.AuthoredRawPaths], TimeSpan.Zero, TestContext.Current.CancellationToken);
return ingestor;
}
private static void FeedBirths(SparkplugIngestor ing, float tempBirthValue = 0f, ulong bdSeq = 1)
{
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1, tempBirthValue));
}
private static SparkplugTopic Topic(SparkplugMessageType type, string? device = null) =>
new(type, Group, Node, device, null);
private static SparkplugPayload Decode(Payload payload) => SparkplugCodec.Decode(payload.ToByteArray());
private static SparkplugPayload Nbirth(ulong seq, ulong bdSeq)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)TahuDataType.Uint64,
LongValue = bdSeq,
});
return Decode(payload);
}
private static SparkplugPayload Dbirth(ulong seq, float tempValue)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
payload.Metrics.Add(BirthMetric("Temperature", TempAlias, tempValue));
payload.Metrics.Add(BirthMetric("Pressure", PressAlias, 0f));
return Decode(payload);
}
private static SparkplugPayload Ddata(ulong seq, params (ulong Alias, TahuDataType Type, float Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
foreach (var (alias, _, value) in metrics)
{
// Deliberately NO Name and NO Datatype: the real post-birth wire shape.
payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value });
}
return Decode(payload);
}
private static SparkplugPayload Ddeath(ulong seq) =>
Decode(new Payload { Seq = seq, Timestamp = 1721822405000UL });
private static Payload.Types.Metric BirthMetric(string name, ulong alias, float value) => new()
{
Name = name,
Alias = alias,
Datatype = (uint)TahuDataType.Float,
FloatValue = value,
};
}
@@ -7,6 +7,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
@@ -539,6 +540,98 @@ public sealed class RoslynVirtualTagEvaluatorTests
$"{starved}/{totalEvaluations} evaluations missed the 5s budget");
}
// ── input quality reaches the script (archreview 02/S14) ────────────────────────────────
/// <summary>
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the dependency's <b>real</b> status.
/// The read cache used to stamp every input <c>Good</c>, so a script could not detect a degraded
/// input at all — "broken input looks Good", the other door onto the same lie 02/S13 closed for
/// broken scripts.
/// </summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_status_code()
{
const uint uncertain = 0x40000000u;
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-quality",
expression: "return (long)ctx.GetTag(\"a\").StatusCode;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, uncertain, stamp, stamp),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe((long)uncertain);
}
/// <summary>The dependency's own source timestamp reaches the script, not evaluation time.</summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_source_timestamp()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-stamp",
expression: "return ctx.GetTag(\"a\").SourceTimestampUtc;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, 0u, stamp, DateTime.UtcNow),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(stamp);
}
/// <summary>
/// The value-only overload keeps working unchanged — it is what the interface's default
/// implementation and the five test fakes still use, which is the whole reason the new overload is
/// default-implemented rather than abstract.
/// </summary>
[Fact]
public void The_value_only_overload_still_evaluates()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
virtualTagId: "vt-values",
expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
dependencies: new Dictionary<string, object?> { ["a"] = 41 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
/// <summary>
/// A fake implementing ONLY the value-only member still answers the snapshot overload, through the
/// interface's default projection. Without that default, adding the overload would have been a
/// breaking change for every existing <see cref="IVirtualTagEvaluator"/> implementation.
/// </summary>
[Fact]
public void The_snapshot_overload_falls_back_to_a_value_only_implementation()
{
IVirtualTagEvaluator fake = new ValueOnlyEvaluator();
var result = fake.Evaluate("vt-fake", "ignored", new Dictionary<string, DataValueSnapshot>
{
["a"] = new(41, 0u, DateTime.UtcNow, DateTime.UtcNow),
});
result.Success.ShouldBeTrue();
result.Value.ShouldBe(41);
}
/// <summary>An evaluator predating the snapshot overload: implements the value-only member only.</summary>
private sealed class ValueOnlyEvaluator : IVirtualTagEvaluator
{
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(dependencies["a"]);
}
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
/// <c>CompiledScriptCache&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
@@ -0,0 +1,226 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// <see cref="OtOpcUaNodeManager.WriteStatuses"/> — the status-only batch write behind the
/// comms-loss annotation for value variables (archreview 03/S15).
///
/// <para><b>Keeping the value is the whole point.</b> The host holds no value cache (v3's
/// single-source design), so "this driver has stopped communicating, but here is what it last
/// said" can only be expressed at the node, where the value already lives. The Part 8
/// last-known-value convention is what a SCADA client expects, and the interface's default
/// implementation — which nulls the value because a value-less sink has nothing else to write —
/// would silently erase it if the SDK sink ever stopped overriding it.</para>
///
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
/// which lazily creates.</para>
///
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
/// would receive.</para>
/// </summary>
public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-writestatuses-{Guid.NewGuid():N}");
/// <summary>
/// The contract: value retained, status + timestamp replaced. Asserted on a node that was first
/// written a real value, because "value retained" is unfalsifiable on a node that never had one.
/// </summary>
[Fact]
public async Task WriteStatuses_replaces_status_and_timestamp_but_keeps_the_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t1 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 8, 20, 10, 5, 0, DateTimeKind.Utc);
nm.EnsureVariable("Plant/Modbus/sim/HR5", parentFolderNodeId: null, displayName: "HR5",
dataType: "Int32", writable: false, realm: AddressSpaceRealm.Raw);
nm.WriteValue("Plant/Modbus/sim/HR5", 42, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw);
nm.WriteStatuses([new StatusWrite("Plant/Modbus/sim/HR5", OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)]);
var v = nm.TryGetVariable("Plant/Modbus/sim/HR5", AddressSpaceRealm.Raw);
v.ShouldNotBeNull();
v!.Value.ShouldBe(42, "the last known value must survive the comms-loss annotation");
v.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
v.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad, "the sub-code reaches the SDK verbatim (#519)");
v.Timestamp.ShouldBe(t2);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>
/// Realm-qualified, like every other node-naming sink call: the same <c>s=</c> id in the other
/// namespace is a different node and must be untouched.
/// </summary>
[Fact]
public async Task WriteStatuses_only_touches_the_named_realm()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
const string sharedId = "Shared/both/realms";
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
nm.WriteStatuses([new StatusWrite(sharedId, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw)]);
nm.TryGetVariable(sharedId, AddressSpaceRealm.Raw)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
nm.TryGetVariable(sharedId, AddressSpaceRealm.Uns)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.Good);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>
/// An unknown NodeId is a no-op: no node is created, nothing throws, and the rest of the batch
/// still lands. A create here would materialise a node nobody authored, holding no value, from a
/// driver-index entry that is stale precisely because the node was removed.
/// </summary>
[Fact]
public async Task An_unknown_node_id_is_skipped_not_created()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
var before = nm.VariableCount;
Should.NotThrow(() => nm.WriteStatuses(
[
new StatusWrite("Plant/does/not/exist", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
new StatusWrite("Plant/known", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
]));
nm.VariableCount.ShouldBe(before, "a status write must never create a node");
nm.TryGetVariable("Plant/does/not/exist", AddressSpaceRealm.Raw).ShouldBeNull();
nm.TryGetVariable("Plant/known", AddressSpaceRealm.Raw)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>
/// A malformed item does not drop the tail — the same per-item isolation contract
/// <see cref="OtOpcUaNodeManager.WriteValues"/> carries, reported through the same
/// <see cref="ValueWriteBatchException"/> aggregate.
/// </summary>
[Fact]
public async Task A_failing_item_neither_drops_the_tail_nor_hides_which_node_failed()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteStatuses(
[
new StatusWrite("Plant/first", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
new StatusWrite(string.Empty, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
new StatusWrite("Plant/third", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
]));
ex.FailedCount.ShouldBe(1);
ex.SucceededCount.ShouldBe(2);
ex.FirstFailedNodeId.ShouldBe(string.Empty);
nm.TryGetVariable("Plant/first", AddressSpaceRealm.Raw)!.Value.ShouldBe(1);
nm.TryGetVariable("Plant/third", AddressSpaceRealm.Raw)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication, "the item after the failing one must not be dropped");
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>An empty batch neither throws nor touches the address space.</summary>
[Fact]
public async Task Empty_batch_is_a_no_op()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var before = nm.VariableCount;
Should.NotThrow(() => nm.WriteStatuses([]));
nm.VariableCount.ShouldBe(before);
}
finally
{
await host.DisposeAsync();
}
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.WriteStatusesTest",
ApplicationUri = $"urn:OtOpcUa.WriteStatusesTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}
@@ -0,0 +1,298 @@
using System.Diagnostics.Metrics;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Archreview <b>03/S15</b> — the value-node half of the comms-loss fan-out. Until this, a driver
/// declared comms-lost annotated its Part 9 conditions and pushed a synthetic Bad into the dependency
/// mux, but its <b>raw and UNS value variables</b> — the product's external surface — got nothing: an
/// OPC UA client subscribed to a value node saw the last value at <b>Good</b> quality for the whole
/// outage, for every driver whose failure shape is "reader stops producing" rather than "per-tag Bad
/// snapshot".
///
/// <para><b>Status-only, value retained.</b> The write goes through
/// <see cref="OpcUaPublishActor.AttributeStatusBatchUpdate"/> so the node keeps its last known value
/// under <c>BadNoCommunication</c> (OPC UA Part 8's convention). The host holds no value cache — v3's
/// single-source design — so only the node itself can retain it.</para>
///
/// <para><b>Nothing is published on reconnect</b>, deliberately: publishing Good would assert a
/// quality this actor has not observed. Recovery is the driver's own re-publish (the poll engine's
/// <c>forceRaise</c> on a fresh subscription, or the push-model initial push). That contract is
/// end-to-end tested in <see cref="DriverHostCommsLossRecoveryTests"/>.</para>
/// </summary>
public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-values");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('c', 64));
private static readonly DateTime TsA = new(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
/// <summary>Audit delay used by the T9 cases — long enough that a same-turn prune wins the race,
/// short enough to observe.</summary>
private static readonly TimeSpan AuditDelay = TimeSpan.FromMilliseconds(400);
// Distinct driver ids per metered test: the meter tag is what the recorder filters on, and a
// MeterListener sees every measurement in the process — including a parallel test's (#503).
private const string SilentDriver = "drv-audit-silent";
private const string RepublishingDriver = "drv-audit-republish";
private const string PartialDriver = "drv-audit-partial";
/// <summary>
/// A disconnect produces ONE status batch naming exactly the disconnected driver's raw nodes plus
/// every referencing UNS mirror, all <c>BadNoCommunication</c>. The second driver's ref is the
/// control: a fan-out that walked every value ref on the node instead of the per-driver index would
/// mark a perfectly healthy driver's nodes Bad, and no assertion about the first driver's nodes
/// would notice.
/// </summary>
[Fact]
public void Disconnect_marks_the_drivers_raw_and_uns_value_nodes_BadNoCommunication()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
(Driver: "drv-2", DriverName: "S7", Device: "dev2", Tag: "other", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
batch.Items.Select(i => i.NodeId).ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
"filling/line1/station1/speed",
"Plant/Modbus/dev1/temp",
}, ignoreOrder: true);
batch.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication);
batch.Items.Single(i => i.NodeId == "Plant/Modbus/dev1/speed").Realm.ShouldBe(AddressSpaceRealm.Raw);
batch.Items.Single(i => i.NodeId == "filling/line1/station1/speed").Realm.ShouldBe(AddressSpaceRealm.Uns);
// The control: the OTHER driver's node is untouched by drv-1's outage.
batch.Items.ShouldNotContain(i => i.NodeId == "Plant/S7/dev2/other");
// ONE batch, not one message per node.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// A reconnect publishes <b>nothing</b> to the value nodes. Writing Good here would claim a quality
/// the host has not observed — the device may be answering the transport and still returning
/// garbage — and it would race the driver's own first real value.
/// </summary>
[Fact]
public void Reconnect_publishes_no_value_or_status_message()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
// ---------------------------------------------------------------------------------
// T9 — the recovery audit (the dedup-trap tripwire)
// ---------------------------------------------------------------------------------
/// <summary>
/// The host publishes nothing Good on reconnect, so the only way a stuck-Bad node becomes visible
/// is this audit: refs still latched some time after the driver reconnected are logged and metered.
/// Here the driver re-publishes nothing at all — the pre-STAB-18 MQTT shape, and the exact failure
/// any future push driver without an initial push would produce.
/// </summary>
[Fact]
public void Refs_still_latched_after_a_reconnect_are_logged_and_metered()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: SilentDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
(Driver: SilentDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: false));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(TimeSpan.FromSeconds(10), () =>
actor.Tell(new DriverInstanceActor.ConnectivityChanged(SilentDriver, Connected: true)));
// Two REFS, not three nodes: the latch is keyed by RawPath, and the UNS mirror shares its
// backing tag's ref — the operator is being told how many tags did not come back, not how many
// address-space nodes they project onto.
AwaitCondition(() => meter.WithTag("driver", SilentDriver) > 0, TimeSpan.FromSeconds(5));
meter.SumWithTag("driver", SilentDriver).ShouldBe(2);
}
/// <summary>
/// <b>The control.</b> A driver that DOES re-publish every latched ref on reconnect trips nothing —
/// without this, the test above would pass against an audit that fires unconditionally, which would
/// make the Warning noise rather than signal.
/// </summary>
[Fact]
public void A_driver_that_republishes_every_ref_trips_no_audit()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: RepublishingDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
(Driver: RepublishingDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: false));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
EventFilter.Warning(contains: "still BadNoCommunication").Expect(0, TimeSpan.FromSeconds(5), () =>
{
actor.Tell(new DriverInstanceActor.ConnectivityChanged(RepublishingDriver, Connected: true));
// The driver's own re-publish — identical values, which is precisely the case a value-only
// dedup would swallow; here it is the driver that force-raised, so both refs are pruned.
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished(RepublishingDriver, new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsA),
}));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
});
meter.WithTag("driver", RepublishingDriver).ShouldBe(0);
}
/// <summary>
/// A partial recovery is still reported, and reports only what is missing: one ref comes back, the
/// other does not, and the audit counts exactly one. An audit that reported the whole latch (or
/// nothing at all once anything arrived) would be useless on precisely the mixed case an operator
/// needs it for.
/// </summary>
[Fact]
public void A_partial_recovery_reports_only_the_refs_that_did_not_come_back()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: PartialDriver, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
(Driver: PartialDriver, DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, commsRecoveryAuditDelay: AuditDelay);
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: false));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(PresenceBudget);
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(TimeSpan.FromSeconds(10), () =>
{
actor.Tell(new DriverInstanceActor.ConnectivityChanged(PartialDriver, Connected: true));
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished(PartialDriver, new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
}));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
});
AwaitCondition(() => meter.WithTag("driver", PartialDriver) > 0, TimeSpan.FromSeconds(5));
meter.SumWithTag("driver", PartialDriver).ShouldBe(1, "only the ref that never came back is reported");
}
/// <summary>
/// A driver that owns no value refs produces no message at all — an empty batch would cost a hop
/// and a sink call for nothing on every flap of an alarm-only or freshly-added driver.
/// </summary>
[Fact]
public void A_driver_with_no_value_refs_publishes_nothing()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-unknown", Connected: false));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// Sums one counter's measurements, <b>filtered by tag</b>. A <see cref="MeterListener"/> observes
/// every measurement in the process, so filtering on instrument name alone would fold in a parallel
/// test's (#503) — each test here therefore uses its own driver id and asserts on that tag.
/// </summary>
private sealed class MeterRecorder : IDisposable
{
private readonly MeterListener _listener;
private readonly List<(long Value, KeyValuePair<string, object?>[] Tags)> _measurements = new();
private readonly object _gate = new();
public MeterRecorder(string instrumentName)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == instrumentName)
listener.EnableMeasurementEvents(instrument);
},
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate) _measurements.Add((value, tags.ToArray()));
});
_listener.Start();
}
public int WithTag(string key, string value)
{
lock (_gate) return _measurements.Count(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)));
}
public long SumWithTag(string key, string value)
{
lock (_gate)
return _measurements.Where(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)))
.Sum(m => m.Value);
}
public void Dispose() => _listener.Dispose();
}
}
@@ -0,0 +1,237 @@
using System.Diagnostics.Metrics;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// <b>The 03/S15 contract test: comms loss → value nodes Bad → the driver's own re-publish clears
/// them.</b> A real <see cref="DriverInstanceActor"/> is spawned by a real
/// <see cref="DriverHostActor"/> around a scripted driver, so the whole seam runs: the child's
/// <c>Reconnecting</c> entry announces comms loss, the host stamps the value nodes
/// <c>BadNoCommunication</c>, the child reconnects and re-subscribes, and the driver's initial push is
/// what makes the nodes Good again.
///
/// <para>⚠️ <b>Vacuity rule — the pushed value is IDENTICAL to the pre-outage one.</b> The host writes
/// nothing Good on reconnect by design, so recovery depends entirely on a re-publish landing; and every
/// dedup on that path (a driver's last-value cache, a consumer's last-published memory) keys on value.
/// A recovery test whose driver pushes a <em>different</em> value therefore passes against a build with
/// no force-republish anywhere — the value change alone defeats every dedup — and is not this
/// contract's guard. The same-value push is the whole point.</para>
///
/// <para>The falsification control is
/// <see cref="A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit"/>: a driver whose
/// <c>SubscribeAsync</c> pushes nothing (the pre-STAB-18 MQTT shape) leaves the nodes Bad and is caught
/// only by the audit. It proves the test above can fail.</para>
/// </summary>
public sealed class DriverHostCommsLossRecoveryTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-recovery");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64));
/// <summary>Audit window measured from the RECONNECT, not from the outage — the initial push lands
/// within milliseconds of Connected, so this is generous.</summary>
private static readonly TimeSpan AuditDelay = TimeSpan.FromSeconds(2);
/// <summary>The child's reconnect retry runs on <c>DriverInstanceActor.DefaultReconnectInterval</c>
/// (10 s) and the host exposes no seam to shorten it, so every budget here clears that.</summary>
private static readonly TimeSpan ReconnectBudget = TimeSpan.FromSeconds(25);
private const string SpeedRef = "Plant/Modbus/dev1/speed";
private const string UnsSpeedNode = "filling/line1/station1/speed";
[Fact]
public void An_identical_value_pushed_on_resubscribe_recovers_the_nodes()
{
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-recover", InitialPush = (SpeedRef, 5.0, OpcUaStatus.Good) };
var (host, publish) = Arrange(driver, "drv-recover");
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
// Steady state: the driver's FIRST subscribe already pushed 5 Good, and the host fanned it to
// raw + UNS. (The push on subscribe is the behaviour under test, so it is also how the pre-outage
// value gets there — no separate seeding call, which would leave an extra batch in the probe.)
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
first.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
first.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
// The outage: the child re-enters Reconnecting and announces comms loss to the host.
host.Tell(new ReconnectDriver("c1", "drv-recover", "test-operator", Guid.NewGuid()));
var staled = publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
staled.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
staled.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication);
// Recovery: the reconnect re-subscribes, and the driver pushes the SAME value it held before.
var recovered = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(ReconnectBudget);
recovered.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
recovered.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.Good);
recovered.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
// …and the audit finds nothing outstanding.
EventFilter.Warning(contains: "still BadNoCommunication")
.Expect(0, AuditDelay + TimeSpan.FromSeconds(3), () => { });
meter.SumWithTag("driver", "drv-recover").ShouldBe(0);
}
/// <summary>
/// <b>The falsification control.</b> Same scenario, but the driver's <c>SubscribeAsync</c> pushes
/// nothing — exactly what MQTT did before 05/STAB-18, and what any push driver without an initial
/// push does. The nodes stay Bad (the host writes no Good of its own), and the ONLY thing that says
/// so is the recovery audit.
/// </summary>
[Fact]
public void A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit()
{
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-silent", InitialPush = null };
var (host, publish) = Arrange(driver, "drv-silent");
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
driver.FireDataChange(SpeedRef, 5.0, OpcUaStatus.Good);
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(ReconnectBudget, () =>
{
host.Tell(new ReconnectDriver("c1", "drv-silent", "test-operator", Guid.NewGuid()));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
// Nothing Good ever arrives — assert it rather than merely not looking.
publish.ExpectNoMsg(TimeSpan.FromSeconds(1));
});
// One ref (the raw tag); its UNS mirror shares it.
AwaitCondition(() => meter.SumWithTag("driver", "drv-silent") == 1, TimeSpan.FromSeconds(5));
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(2,
"the child must actually have re-subscribed — otherwise this proves nothing about the push");
}
private (IActorRef Host, Akka.TestKit.TestProbe Publish) Arrange(ScriptedSubscribeDriver driver, string driverId)
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
enabled: true);
var (host, publish, _) = SpawnHostAndApply(
this, db, TestNode, deploymentId, RevA,
commsRecoveryAuditDelay: AuditDelay,
driverFactory: new SingleDriverFactory(driver));
// The child is spawned + initialised by the apply; wait for its first subscribe so the scripted
// pushes below are attributed to a live subscription.
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
return (host, publish);
}
/// <summary>Hands the host one scripted driver, whatever type/instance it asks for.</summary>
private sealed class SingleDriverFactory(IDriver driver) : IDriverFactory
{
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => driver;
public IReadOnlyCollection<string> SupportedTypes { get; } = ["Modbus"];
}
/// <summary>
/// A subscribable driver whose <see cref="SubscribeAsync"/> optionally performs an <b>initial
/// push</b> — the poll engine's <c>forceRaise: true</c> and MQTT's post-STAB-18 behaviour, modelled
/// at the seam the host actually observes. With <see cref="InitialPush"/> null it is the silent
/// shape the control test needs.
/// </summary>
private sealed class ScriptedSubscribeDriver : IDriver, ISubscribable
{
private readonly StubHandle _handle = new();
private int _subscribeCount;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>The value re-published on every (re)subscribe, or null for a silent driver.</summary>
public (string Ref, object? Value, uint StatusCode)? InitialPush { get; init; }
/// <summary>The instance id the child actor adopts — <c>DriverInstanceActor</c> takes it from the
/// DRIVER, not from the spec, and the host's ref map is keyed by the spec's id. They must agree or
/// every published value is dropped as unmapped.</summary>
public required string InstanceId { get; init; }
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
public string DriverInstanceId => InstanceId;
public string DriverType => "Modbus";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// Stable health: the #527 watchdog must not fire on its own here — the outage in these tests is
// driven explicitly, and a health-driven trip would make the timing non-deterministic.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeCount);
if (InitialPush is { } push && fullReferences.Contains(push.Ref))
FireDataChange(push.Ref, push.Value, push.StatusCode);
return Task.FromResult<ISubscriptionHandle>(_handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
}
}
/// <summary>Tag-filtered counter recorder — see the note in
/// <c>DriverHostActorCommsLossValueNodesTests</c> on why the instrument name alone is not enough.</summary>
private sealed class MeterRecorder : IDisposable
{
private readonly MeterListener _listener;
private readonly List<(long Value, KeyValuePair<string, object?>[] Tags)> _measurements = new();
private readonly object _gate = new();
public MeterRecorder(string instrumentName)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == instrumentName)
listener.EnableMeasurementEvents(instrument);
},
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate) _measurements.Add((value, tags.ToArray()));
});
_listener.Start();
}
public long SumWithTag(string key, string value)
{
lock (_gate)
return _measurements.Where(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)))
.Sum(m => m.Value);
}
public void Dispose() => _listener.Dispose();
}
}
@@ -374,4 +374,54 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3));
}
/// <summary>
/// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way
/// out, so the host latches its value nodes <c>BadNoCommunication</c>.
/// <para>Every other route into <c>Reconnecting</c> announces it (watchdog trip, failed initial
/// connect, Force Reconnect all run the <c>Reconnecting</c> entry). Stopping did not: an operator's
/// Restart and a supervision respawn tore the child down silently, so its nodes kept serving their
/// last value at Good across the gap. The fresh child's re-subscribe + initial push (03/S16) is what
/// clears them again.</para>
/// </summary>
[Fact]
public void Stopping_a_connected_child_announces_comms_loss_to_its_parent()
{
var parent = CreateTestProbe();
var driver = new StubDriver();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeTrue();
parent.Watch(actor);
parent.Send(actor, PoisonPill.Instance);
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
parent.ExpectTerminated(actor, PresenceBudget);
}
/// <summary>
/// <b>Control.</b> A child that never reached <c>Connected</c> announces nothing when stopped — its
/// nodes are still <c>BadWaitingForInitialData</c>, which is a more precise statement than
/// "communication lost", and overwriting it would claim an outage that never happened.
/// </summary>
[Fact]
public void Stopping_a_never_connected_child_announces_nothing()
{
var parent = CreateTestProbe();
var driver = new StubDriver { InitializeShouldThrow = true };
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// The failed initial connect announces Connected=false from the Reconnecting entry; consume it, so
// what follows is only what STOPPING produced.
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
parent.Watch(actor);
parent.Send(actor, PoisonPill.Instance);
parent.ExpectTerminated(actor, PresenceBudget);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}
@@ -140,15 +140,20 @@ public static class V3ArtifactSeeder
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
/// deferral without waiting the production window.</param>
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
/// which is far longer than any test's lifetime.</param>
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
IDriverFactory? driverFactory = null,
TimeSpan? reprovisionBackoff = null)
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null)
{
var publish = kit.CreateTestProbe();
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff);
var (actor, mux, coordinator) =
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
Dispatch(actor, coordinator, deploymentId, rev);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
@@ -175,7 +180,8 @@ public static class V3ArtifactSeeder
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff)
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff,
TimeSpan? commsRecoveryAuditDelay = null)
{
var coordinator = kit.CreateTestProbe();
var mux = kit.CreateTestProbe();
@@ -189,7 +195,8 @@ public static class V3ArtifactSeeder
opcUaPublishActor: opcUaPublishActor,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref,
reprovisionBackoff: reprovisionBackoff));
reprovisionBackoff: reprovisionBackoff,
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
return (actor, mux, coordinator);
}
@@ -0,0 +1,197 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
/// <summary>
/// <see cref="OpcUaPublishActor.AttributeStatusBatchUpdate"/> — the status-only route to the sink
/// that the comms-loss fan-out uses (archreview 03/S15). It must reach
/// <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> as exactly one call, never decompose into
/// value writes (which would null every node's value), and survive a throwing sink.
/// </summary>
public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
{
/// <summary>
/// One message, one <c>WriteStatuses</c> call carrying every item verbatim — and <b>zero</b> value
/// writes. The second half is load-bearing: a handler that fell through to <c>WriteValues</c> (or
/// to the interface's default, which loops <c>WriteValue</c> with a null value) would satisfy "the
/// status arrived" while erasing the last known value the annotation exists to keep.
/// </summary>
[Fact]
public void Status_batch_reaches_sink_as_one_WriteStatuses_call()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
var items = new[]
{
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
new StatusWrite("AreaA/Line1/Eq1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Uns),
};
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(items));
AwaitAssert(() => sink.StatusBatches.Count.ShouldBe(1), PresenceBudget);
var batch = sink.StatusBatches[0];
batch.Count.ShouldBe(2);
batch[0].ShouldBe(items[0]);
batch[1].ShouldBe(items[1]);
sink.ValueBatches.Count.ShouldBe(0, "a status annotation must never be routed as a value write");
sink.SingleWrites.Count.ShouldBe(0);
}
/// <summary>
/// A partial failure is metered honestly, exactly as the value batch is: the tally advances by the
/// items the sink DID apply, not by zero and not by the whole batch.
/// </summary>
[Fact]
public void Partial_failure_still_counts_the_items_that_landed()
{
var sink = new PartialFailureSink();
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[]
{
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
new StatusWrite("", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
new StatusWrite("Plant/Modbus/dev1/Temp", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
}));
sink.Threw.ShouldBeTrue();
actor.UnderlyingActor.WriteCount.ShouldBe(2,
"the two items the sink DID apply must still be counted — not 0 (the general catch) and not 3");
}
/// <summary>
/// A throwing sink is swallowed and logged, so a node manager fault cannot restart the publish
/// actor and drop live traffic. As in the value-batch suite, survival alone proves nothing (Akka's
/// restart preserves the mailbox) — the load-bearing assertions are that no exception reaches
/// supervision and that the handler logs its own Warning.
/// </summary>
[Fact]
public void Sink_throw_is_swallowed_and_logged()
{
var sink = new ThrowOnWriteStatusesSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
EventFilter.Exception<InvalidOperationException>().Expect(0, () =>
{
EventFilter.Warning(contains: "sink.WriteStatuses threw").ExpectOne(() =>
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate(new[]
{
new StatusWrite("Plant/Modbus/dev1/Speed", OpcUaStatus.BadNoCommunication, t0, AddressSpaceRealm.Raw),
})));
// Survival: a follow-up on the untouched per-item value path lands only if the actor is pumping.
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
"Plant/Modbus/dev1/Temp", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget);
});
sink.Threw.ShouldBeTrue();
}
private void AwaitAssert(Action assertion, TimeSpan duration)
{
var deadline = DateTime.UtcNow + duration;
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
try { assertion(); return; }
catch (Exception ex) { last = ex; Thread.Sleep(25); }
}
if (last is not null) throw last;
}
/// <summary>Records status batches, value batches and per-item value writes separately, so a handler
/// that routed a status annotation down the value path is distinguishable from one that did not.</summary>
private class RecordingSink : IOpcUaAddressSpaceSink
{
private readonly object _gate = new();
private readonly List<IReadOnlyList<StatusWrite>> _statusBatches = new();
private readonly List<IReadOnlyList<ValueWrite>> _valueBatches = new();
private readonly List<ValueWrite> _singles = new();
public IReadOnlyList<IReadOnlyList<StatusWrite>> StatusBatches { get { lock (_gate) return _statusBatches.ToArray(); } }
public IReadOnlyList<IReadOnlyList<ValueWrite>> ValueBatches { get { lock (_gate) return _valueBatches.ToArray(); } }
public IReadOnlyList<ValueWrite> SingleWrites { get { lock (_gate) return _singles.ToArray(); } }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (_gate) _singles.Add(new ValueWrite(nodeId, value, statusCode, sourceTimestampUtc, realm));
}
public void WriteValues(IReadOnlyList<ValueWrite> values)
{
lock (_gate) _valueBatches.Add(values.ToArray());
}
// Overrides the interface's default (which loops WriteValue with a NULL value).
public virtual void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
lock (_gate) _statusBatches.Add(statuses.ToArray());
}
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>Throws a PLAIN exception out of the status batch — drives the handler's general catch.</summary>
private sealed class ThrowOnWriteStatusesSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
Interlocked.Exchange(ref _threw, 1);
throw new InvalidOperationException("simulated status-write fault");
}
}
/// <summary>Models the real sink's PARTIAL failure: applies every item with a non-empty NodeId, then
/// reports the aggregate as a <see cref="ValueWriteBatchException"/>.</summary>
private sealed class PartialFailureSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
var applied = statuses.Where(s => !string.IsNullOrEmpty(s.NodeId)).ToArray();
var failedCount = statuses.Count - applied.Length;
if (failedCount == 0)
{
base.WriteStatuses(statuses);
return;
}
base.WriteStatuses(applied);
Interlocked.Exchange(ref _threw, 1);
throw new ValueWriteBatchException(
firstFailedNodeId: statuses.First(s => string.IsNullOrEmpty(s.NodeId)).NodeId,
failedCount: failedCount,
succeededCount: applied.Length,
firstFailure: new ArgumentException("empty NodeId"));
}
}
}
@@ -0,0 +1,91 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// Archreview <b>05/STAB-19</b> — the adapter that carries dependency values from the per-node
/// dependency mux into an <see cref="IDependencyConsumer"/> (today: the Calculation driver).
///
/// <para><b>The defect these pin.</b> The adapter forwarded a hard-coded <c>statusCode: 0u</c> behind
/// a comment claiming "quality is stripped at the mux boundary". That was true once; since #478/#519
/// <see cref="VirtualTagActor.DependencyValueChanged"/> carries the source driver's full status code,
/// sub-code included. The consequence was that a comms-lost input reached the Calculation driver as
/// <em>arrived, Good, null</em> — indistinguishable from a real Good reading of null, which is how a
/// calc output kept publishing Good over a dead input.</para>
/// </summary>
public sealed class DependencyConsumerMuxAdapterTests : RuntimeActorTestBase
{
private const string TagA = "Plant/Modbus/dev1/a";
private const string TagB = "Plant/Modbus/dev1/b";
/// <summary>Single delivery: the status code reaches the consumer verbatim, sub-code and all.</summary>
[Fact]
public void A_single_dependency_value_carries_its_status_code_to_the_consumer()
{
var (consumer, adapter) = Spawn();
adapter.Tell(new VirtualTagActor.DependencyValueChanged(
TagA, Value: null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
AwaitCondition(() => consumer.Received.Count == 1, PresenceBudget);
consumer.Received[0].Ref.ShouldBe(TagA);
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
consumer.Received[0].StatusCode.ShouldNotBe(OpcUaStatus.Good,
"a Bad input forwarded as Good is the laundering 05/STAB-19 is about");
}
/// <summary>
/// Batched delivery goes through the same seam, so it must carry status too — the batch path is
/// what a real driver burst uses, and a fix applied only to the single-value path would be inert
/// in production.
/// </summary>
[Fact]
public void A_batched_delivery_carries_each_items_status_code()
{
var (consumer, adapter) = Spawn();
var ts = DateTime.UtcNow;
adapter.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged(TagA, 1.0, ts, OpcUaStatus.Good),
new VirtualTagActor.DependencyValueChanged(TagB, null, ts, OpcUaStatus.BadNoCommunication),
}));
AwaitCondition(() => consumer.Received.Count == 2, PresenceBudget);
consumer.Received[0].StatusCode.ShouldBe(OpcUaStatus.Good);
consumer.Received[1].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
private (RecordingConsumer Consumer, IActorRef Adapter) Spawn()
{
var consumer = new RecordingConsumer();
var mux = CreateTestProbe();
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(consumer, mux.Ref));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(PresenceBudget);
return (consumer, adapter);
}
private sealed class RecordingConsumer : IDependencyConsumer
{
private readonly List<(string Ref, object? Value, uint StatusCode)> _received = new();
private readonly object _gate = new();
public IReadOnlyList<(string Ref, object? Value, uint StatusCode)> Received
{
get { lock (_gate) return _received.ToArray(); }
}
public IReadOnlyCollection<string> DependencyRefs { get; } = [TagA, TagB];
public void OnDependencyValue(string fullReference, object? value, uint statusCode, DateTime timestampUtc)
{
lock (_gate) _received.Add((fullReference, value, statusCode));
}
}
}
@@ -0,0 +1,315 @@
using System.Diagnostics.Metrics;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// Archreview <b>02/S14</b> — what a virtual tag does with its dependencies' <b>quality</b>.
///
/// <para><b>The defect.</b> The actor stored dependency values only, evaluated unconditionally, and
/// the Roslyn read cache stamped every input Good — so a VT over a comms-lost input kept publishing
/// fresh-Good results, and a script author could not detect it because
/// <c>ctx.GetTag(path).StatusCode</c> read the fabricated Good. 02/S13 fixed "broken script looks
/// Good"; this is the same lie through the other door.</para>
///
/// <para><b>The policy</b> is the Calculation driver's and the alarm engine's, stated once in
/// <c>InputQuality</c>: hold on Bad and publish the worst input's sub-code; evaluate through
/// Uncertain and stamp the output with it. The R2-03 decisions are kept, not re-litigated —
/// transition-once, the inputs-ready gate, and force-republish on recovery.</para>
///
/// <para>⚠️ <b>Vacuity rule:</b> the recovery case computes the <b>identical</b> value it had before
/// the outage. A recovery with a different value passes against a value-only dedup key and is not the
/// guard — that dedup is exactly the "Bad forever" trap.</para>
/// </summary>
public sealed class VirtualTagActorInputQualityTests : RuntimeActorTestBase
{
private const uint Uncertain = 0x40000000u;
/// <summary>
/// A Bad input holds the evaluator and publishes that input's own sub-code, once, carrying the
/// last known value. The evaluator-call count is the load-bearing half: "the result is Bad" could
/// also be produced by running the script against a null and failing, which is the NRE storm this
/// replaces.
/// </summary>
[Fact]
public void A_bad_input_holds_evaluation_and_publishes_the_inputs_sub_code()
{
var parent = CreateTestProbe();
var evaluator = new CountingSumEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(10);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(42);
var callsBefore = evaluator.Calls;
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
var bad = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
bad.StatusCode.ShouldNotBe(OpcUaStatus.BadInternalError, "the script did not fail — its input did");
bad.Value.ShouldBe(42, "the last known value rides out with the Bad status");
evaluator.Calls.ShouldBe(callsBefore, "the script must NOT be run against a Bad input");
// Transition-once: the same Bad again changes nothing.
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>A different Bad sub-code republishes — the sub-code is the diagnostic.</summary>
[Fact]
public void A_different_bad_sub_code_is_republished()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadWaitingForInitialData));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
}
/// <summary>
/// <b>The vacuity-sensitive assertion.</b> The input recovers holding the value it had before, so
/// the computed result is identical to the pre-outage one — and it must still be published Good,
/// because the node is currently Bad and only a publish can clear it.
/// </summary>
[Fact]
public void An_identical_value_is_republished_when_recovering_from_a_bad_input()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(10);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Same value as before the outage.
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
var recovered = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
recovered.Value.ShouldBe(10, "the same value as before — that is the point");
recovered.StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>
/// An Uncertain input is usable: the script runs and the output inherits the Uncertain code. The
/// script also sees the real code — a capturing evaluator asserts it, because a VT author writing
/// a quality-aware script is the reason the read cache had to stop fabricating Good.
/// </summary>
[Fact]
public void An_uncertain_input_is_evaluated_and_stamps_the_output_uncertain()
{
var parent = CreateTestProbe();
var evaluator = new CountingSumEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a", evaluator: evaluator, dependencyRefs: ["a"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 11, DateTime.UtcNow, Uncertain));
var result = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
result.Value.ShouldBe(11, "the script DID run — Uncertain is usable");
result.StatusCode.ShouldBe(Uncertain);
evaluator.LastSeenStatus.ShouldBe(Uncertain, "the script must observe the input's REAL status");
}
/// <summary>
/// The R2-03 inputs-ready gate is preserved: with one declared dependency never pushed, a Bad
/// arrival on the other publishes nothing — cold start is not a fault.
/// </summary>
[Fact]
public void Nothing_is_published_while_a_declared_dependency_has_never_arrived()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a", "b"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>
/// The <c>inputs-bad</c> outcome counter must tick only when a tag actually <b>reports</b> Bad
/// inputs — not on every warm-up trigger, where a declared dependency simply has not arrived yet.
/// Metering above the readiness gate turns ordinary cold start into a stream of "inputs bad"
/// measurements, which is exactly the noise that makes an operator stop trusting the counter.
/// <para>The absence half is paired with a positive control in the same test — the counter DOES
/// tick once the tag reports — so "no measurements" can never pass for want of a working listener.</para>
/// </summary>
[Fact]
public void The_inputs_bad_counter_does_not_tick_during_warm_up()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a + b", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a", "b"]));
using var meter = new OutcomeRecorder();
// Warm-up: "b" has never arrived, so this is cold start, not a fault.
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
meter.Count("inputs-bad").ShouldBe(0, "warm-up is not an inputs-bad report");
// Positive control: once every dep has arrived, the tag reports — and the counter ticks.
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget)
.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
AwaitCondition(() => meter.Count("inputs-bad") == 1, TimeSpan.FromSeconds(3));
}
/// <summary>
/// A deploy's <c>ReassertValue</c> re-emits the LAST PUBLISHED status, so a VT whose input is
/// comms-lost re-asserts <c>BadNoCommunication</c> onto the freshly-materialised node rather than
/// Good. Re-asserting Good here would re-introduce the stale-Good lie at every deploy.
/// </summary>
[Fact]
public void Reassert_carries_the_last_published_status()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a", evaluator: new CountingSumEvaluator(), dependencyRefs: ["a"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
actor.Tell(VirtualTagActor.ReassertValue.Instance);
var reassert = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
reassert.IsReassert.ShouldBeTrue();
reassert.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
reassert.Value.ShouldBe(10);
}
/// <summary>
/// The batched mux delivery goes through the same gate: a burst whose worst item is Bad holds the
/// script and publishes that sub-code. A fix applied only to the single-value path would be inert
/// in production, where the mux batches.
/// </summary>
[Fact]
public void A_batch_containing_a_bad_item_holds_evaluation_too()
{
var parent = CreateTestProbe();
var evaluator = new CountingSumEvaluator();
var actor = parent.ChildActorOf(VirtualTagActor.Props(
"vt-1", "a + b", evaluator: evaluator, dependencyRefs: ["a", "b"]));
var ts = DateTime.UtcNow;
actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged("a", 10, ts),
new VirtualTagActor.DependencyValueChanged("b", 32, ts),
}));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Value.ShouldBe(42);
var callsBefore = evaluator.Calls;
actor.Tell(new VirtualTagActor.DependencyValueBatch(new[]
{
new VirtualTagActor.DependencyValueChanged("a", null, ts, OpcUaStatus.BadNoCommunication),
new VirtualTagActor.DependencyValueChanged("b", 33, ts),
}));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget)
.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
evaluator.Calls.ShouldBe(callsBefore);
}
/// <summary>
/// Counts <c>otopcua.virtualtag.eval</c> measurements by their <c>outcome</c> tag.
/// <para>A <see cref="MeterListener"/> sees every measurement in the process, and this counter is
/// hot across the assembly — so the assertions above are scoped to the <c>inputs-bad</c> outcome,
/// which only this suite produces (nothing else drives a Bad dependency), and the absence
/// assertion is paired with a positive control rather than standing alone.</para>
/// </summary>
private sealed class OutcomeRecorder : IDisposable
{
private readonly MeterListener _listener;
private readonly List<string> _outcomes = new();
private readonly object _gate = new();
public OutcomeRecorder()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName
&& instrument.Name == "otopcua.virtualtag.eval")
listener.EnableMeasurementEvents(instrument);
},
};
_listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
{
foreach (var tag in tags)
{
if (tag.Key != "outcome" || tag.Value is not string outcome) continue;
lock (_gate) _outcomes.Add(outcome);
}
});
_listener.Start();
}
public int Count(string outcome)
{
lock (_gate) return _outcomes.Count(o => o == outcome);
}
public void Dispose() => _listener.Dispose();
}
/// <summary>
/// Sums the dependency values, counts its invocations, and records the status code the actor
/// handed it for "a" — so a test can assert both that the script did NOT run and that, when it
/// did, it saw the input's real quality.
/// </summary>
private sealed class CountingSumEvaluator : IVirtualTagEvaluator
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public uint? LastSeenStatus { get; private set; }
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
Interlocked.Increment(ref _calls);
return VirtualTagEvalResult.Ok(Sum(dependencies.Values));
}
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
Interlocked.Increment(ref _calls);
LastSeenStatus = dependencies.TryGetValue("a", out var a) ? a.StatusCode : null;
return VirtualTagEvalResult.Ok(Sum(dependencies.Values.Select(v => v.Value)));
}
private static object Sum(IEnumerable<object?> values) =>
values.Where(v => v is not null).Sum(v => Convert.ToInt32(v));
}
}
@@ -201,6 +201,38 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
value.ServerTimestampUtc.ShouldBe(ts);
}
/// <summary>
/// Archreview 02/S14 — a result carrying a propagated INPUT status (<c>BadNoCommunication</c>,
/// because the VT held evaluation on a comms-lost dependency) is bridged verbatim to the publish
/// actor <b>and historized with that status</b>, the same way 02/S13's script-failure Bad is.
/// A bridge that special-cased status — or historized it as Good — would put the stale-Good lie
/// back one hop downstream of the fix.
/// </summary>
[Fact]
public void A_result_carrying_a_propagated_input_status_is_bridged_and_historized_with_it()
{
const uint badNoCommunication = 0x80310000u;
var publish = CreateTestProbe();
var writer = new CapturingHistoryWriter();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));
var ts = new DateTime(2026, 8, 20, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult(
"vt-1", 42.0, ts, CorrelationId.NewId(), badNoCommunication));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/speed-rpm");
update.StatusCode.ShouldBe(badNoCommunication);
update.Value.ShouldBe(42.0, "the last known value rides out under the Bad status");
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
writer.Calls.TryPeek(out var captured).ShouldBeTrue();
captured.Item2.StatusCode.ShouldBe(badNoCommunication);
}
/// <summary>H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer
/// is never called — though the value is still published.</summary>
[Fact]