fix(runtime): virtual tags hold on Bad inputs and inherit the worst input status (archreview 02/S14)

This commit is contained in:
Joseph Doherty
2026-08-20 10:53:26 -04:00
parent cc9b845f99
commit e3a72dc385
6 changed files with 417 additions and 20 deletions
@@ -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".
@@ -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"
+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
@@ -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,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<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)
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("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));
}
/// <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 +352,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,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;
/// <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>
/// 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>
/// 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]