fix(runtime): virtual tags hold on Bad inputs and inherit the worst input status (archreview 02/S14)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+241
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user