From e3a72dc385f4ed6b29cd15cb040c73e2a41fb329 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:53:26 -0400 Subject: [PATCH] fix(runtime): virtual tags hold on Bad inputs and inherit the worst input status (archreview 02/S14) --- .../plans/R3-02-stale-good-family-plan.md | 26 ++ ...R3-02-stale-good-family-plan.md.tasks.json | 2 +- docs/ScriptEditor.md | 23 ++ .../VirtualTags/VirtualTagActor.cs | 113 ++++++-- .../VirtualTagActorInputQualityTests.cs | 241 ++++++++++++++++++ .../VirtualTags/VirtualTagHostActorTests.cs | 32 +++ 6 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs diff --git a/archreview/plans/R3-02-stale-good-family-plan.md b/archreview/plans/R3-02-stale-good-family-plan.md index 2fbebca4..ffcff20e 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md +++ b/archreview/plans/R3-02-stale-good-family-plan.md @@ -780,3 +780,29 @@ test could only pass once the production change landed. 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". diff --git a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json index 2bd803a6..25cc7158 100644 --- a/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json +++ b/archreview/plans/R3-02-stale-good-family-plan.md.tasks.json @@ -116,7 +116,7 @@ { "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" diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index 1241db9d..88f7c54c 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index 5520c282..9b33d846 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -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"; /// A dependency's value changed. (#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 , 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. + /// 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 . + /// The virtual-tag engine consumes it (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). 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 _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 _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; + + /// Whether anything has been published for this tag yet — distinct from + /// , because an inputs-Bad publish carries no value of its own. + private bool _hasPublished; + + /// The status last published. Replaces 02/S13's two-state _lastPublishedBad 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. + private uint _lastPublishedStatus = OpcUaStatus.Good; /// Factory method to create Props for a VirtualTagActor. /// Unique identifier for the virtual tag. @@ -178,15 +192,17 @@ public sealed class VirtualTagActor : ReceiveActor /// 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 /// The triggering dependency change's timestamp, stamped on the result. 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("outcome", "skip")); return; @@ -259,12 +293,52 @@ 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("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); } + /// + /// 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. + /// + /// + /// + /// Holding rather than evaluating-and-degrading is what removes the + /// (double)ctx.GetTag(x).Value 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 (AreInputsReady), so all three script seams now agree. + /// + /// + /// No script-log entry, 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. + /// + /// + /// Published once per change of the published status, 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. + /// + /// + /// The worst status among this tag's declared dependencies. + /// The triggering change's timestamp, stamped on the result. + private void OnInputsBad(uint worstInputStatus, DateTime timestampUtc) + { + OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("outcome", "inputs-bad")); + + if (!AllDependenciesArrived()) return; + if (_hasPublished && _lastPublishedStatus == worstInputStatus) return; + + _hasPublished = true; + _lastPublishedStatus = worstInputStatus; + Context.Parent.Tell(new EvaluationResult( + _virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), worstInputStatus)); + } + /// 02/S13 failure tail shared by the evaluator-threw catch and the !Success branch. /// Always meters + publishes the per-failure script-log entry (the pre-existing operator /// diagnostic); additionally publishes a Bad — carrying the @@ -278,12 +352,13 @@ public sealed class VirtualTagActor : ReceiveActor OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair("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)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs new file mode 100644 index 00000000..039c380e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorInputQualityTests.cs @@ -0,0 +1,241 @@ +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.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; + +/// +/// Archreview 02/S14 — what a virtual tag does with its dependencies' quality. +/// +/// The defect. 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 +/// ctx.GetTag(path).StatusCode read the fabricated Good. 02/S13 fixed "broken script looks +/// Good"; this is the same lie through the other door. +/// +/// The policy is the Calculation driver's and the alarm engine's, stated once in +/// InputQuality: 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. +/// +/// ⚠️ Vacuity rule: the recovery case computes the identical 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. +/// +public sealed class VirtualTagActorInputQualityTests : RuntimeActorTestBase +{ + private const uint Uncertain = 0x40000000u; + + /// + /// 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. + /// + [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().Value.ShouldBe(10); + parent.ExpectMsg().Value.ShouldBe(42); + var callsBefore = evaluator.Calls; + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication)); + + var bad = parent.ExpectMsg(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)); + } + + /// A different Bad sub-code republishes — the sub-code is the diagnostic. + [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(); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication)); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadWaitingForInitialData)); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData); + } + + /// + /// The vacuity-sensitive assertion. 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. + /// + [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().Value.ShouldBe(10); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication)); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + + // Same value as before the outage. + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow)); + + var recovered = parent.ExpectMsg(PresenceBudget); + recovered.Value.ShouldBe(10, "the same value as before — that is the point"); + recovered.StatusCode.ShouldBe(OpcUaStatus.Good); + } + + /// + /// 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. + /// + [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(); + + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 11, DateTime.UtcNow, Uncertain)); + + var result = parent.ExpectMsg(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"); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// A deploy's ReassertValue re-emits the LAST PUBLISHED status, so a VT whose input is + /// comms-lost re-asserts BadNoCommunication onto the freshly-materialised node rather than + /// Good. Re-asserting Good here would re-introduce the stale-Good lie at every deploy. + /// + [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(); + actor.Tell(new VirtualTagActor.DependencyValueChanged("a", null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication)); + parent.ExpectMsg(); + + actor.Tell(VirtualTagActor.ReassertValue.Instance); + + var reassert = parent.ExpectMsg(PresenceBudget); + reassert.IsReassert.ShouldBeTrue(); + reassert.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + reassert.Value.ShouldBe(10); + } + + /// + /// 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. + /// + [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().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(PresenceBudget) + .StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + evaluator.Calls.ShouldBe(callsBefore); + } + + /// + /// 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. + /// + 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 dependencies) + { + Interlocked.Increment(ref _calls); + return VirtualTagEvalResult.Ok(Sum(dependencies.Values)); + } + + public VirtualTagEvalResult Evaluate( + string virtualTagId, string expression, IReadOnlyDictionary 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 values) => + values.Where(v => v is not null).Sum(v => Convert.ToInt32(v)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index bb235899..3b759cf7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -201,6 +201,38 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase value.ServerTimestampUtc.ShouldBe(ts); } + /// + /// Archreview 02/S14 — a result carrying a propagated INPUT status (BadNoCommunication, + /// because the VT held evaluation on a comms-lost dependency) is bridged verbatim to the publish + /// actor and historized with that status, 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. + /// + [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(); + 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); + } + /// 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. [Fact]