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`).
|
overload. A test pins that fallback explicitly (`ValueOnlyEvaluator`).
|
||||||
- `RoslynVirtualTagEvaluator.BuildReadCache` is **deleted**; the snapshots are the read cache. The
|
- `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.
|
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",
|
"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",
|
"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": [
|
"blockedBy": [
|
||||||
"T1",
|
"T1",
|
||||||
"T15"
|
"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
|
## Known Limitations / Follow-ups
|
||||||
|
|
||||||
- **Tag-path completion shows resolvable keys only.** Surfacing the UNS browse
|
- **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.Observability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
@@ -31,9 +32,13 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
public const string ScriptLogsTopic = "script-logs";
|
public const string ScriptLogsTopic = "script-logs";
|
||||||
|
|
||||||
/// <summary>A dependency's value changed. <paramref name="StatusCode"/> (#478) carries the source
|
/// <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
|
/// driver's OPC UA status code — since #519 including its sub-code, so a worst-of-input winner keeps
|
||||||
/// quality; it defaults to <see cref="OpcUaStatus.Good"/>, and the virtual-tag engine ignores it.
|
/// the reason it won — and it defaults to <see cref="OpcUaStatus.Good"/>.
|
||||||
/// Since #519 it carries the driver's sub-code, so a worst-of-input winner keeps the reason it won.</summary>
|
/// <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(
|
public sealed record DependencyValueChanged(
|
||||||
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
|
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 IActorRef? _mux;
|
||||||
private readonly ITelemetryLocalHub? _telemetryHub;
|
private readonly ITelemetryLocalHub? _telemetryHub;
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
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 bool _hasLastValue;
|
||||||
private object? _lastValue;
|
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.
|
/// <summary>Whether anything has been published for this tag yet — distinct from
|
||||||
private bool _lastPublishedBad;
|
/// <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>
|
/// <summary>Factory method to create Props for a VirtualTagActor.</summary>
|
||||||
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
|
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
|
||||||
@@ -178,15 +192,17 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
/// </para></summary>
|
/// </para></summary>
|
||||||
private void OnReassertValue()
|
private void OnReassertValue()
|
||||||
{
|
{
|
||||||
if (!_hasLastValue && !_lastPublishedBad) return;
|
if (!_hasLastValue && !_hasPublished) return;
|
||||||
var statusCode = _lastPublishedBad ? OpcUaStatus.BadInternalError : OpcUaStatus.Good;
|
// 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(
|
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)
|
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);
|
Evaluate(msg.TimestampUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +226,8 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
for (var i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
var item = items[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);
|
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>
|
/// <param name="timestampUtc">The triggering dependency change's timestamp, stamped on the result.</param>
|
||||||
private void Evaluate(DateTime timestampUtc)
|
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;
|
VirtualTagEvalResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -249,9 +282,10 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value dedup — but bypass it while recovering from Bad, so a recovery whose value equals the
|
// Dedup on (value, STATUS) — 02/S14 generalising 02/S13's recover-from-Bad bypass. Keying on
|
||||||
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
|
// value alone swallows every recovery whose value is unchanged, which is what a device coming
|
||||||
if (!_lastPublishedBad && _hasLastValue && Equals(_lastValue, result.Value))
|
// 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"));
|
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
|
||||||
return;
|
return;
|
||||||
@@ -259,12 +293,52 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
|
|
||||||
_hasLastValue = true;
|
_hasLastValue = true;
|
||||||
_lastValue = result.Value;
|
_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"));
|
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);
|
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.
|
/// <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
|
/// Always meters + publishes the per-failure script-log entry (the pre-existing operator
|
||||||
/// diagnostic); additionally publishes a Bad <see cref="EvaluationResult"/> — carrying the
|
/// 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"));
|
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
|
||||||
PublishLog(level, reason);
|
PublishLog(level, reason);
|
||||||
|
|
||||||
if (_lastPublishedBad || !AllDependenciesArrived())
|
if ((_hasPublished && _lastPublishedStatus == OpcUaStatus.BadInternalError) || !AllDependenciesArrived())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastPublishedBad = true;
|
_hasPublished = true;
|
||||||
|
_lastPublishedStatus = OpcUaStatus.BadInternalError;
|
||||||
Context.Parent.Tell(new EvaluationResult(
|
Context.Parent.Tell(new EvaluationResult(
|
||||||
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
|
_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);
|
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
|
/// <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>
|
/// is never called — though the value is still published.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user