fix(runtime): virtual tags hold on Bad inputs and inherit the worst input status (archreview 02/S14)
This commit is contained in:
+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