fix(runtime): VT script failure/timeout degrades node quality to Bad once per transition (02/S13)

This commit is contained in:
Joseph Doherty
2026-07-13 09:52:56 -04:00
parent c5ca3e8587
commit 01253818de
3 changed files with 56 additions and 9 deletions
@@ -18,7 +18,7 @@
{
"id": "R2-03-T3",
"subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)",
"status": "pending",
"status": "completed",
"blockedBy": [
"R2-03-T2"
]
@@ -13,8 +13,17 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// Wraps a single virtual-tag expression. Receives dependency-tag updates, recomputes the
/// expression via an injected <see cref="IVirtualTagEvaluator"/>, and emits an
/// <see cref="EvaluationResult"/> to the parent (the publish actor) whenever the value
/// actually changes. Script failures publish a Warning <see cref="ScriptLogEntry"/> on the
/// actually changes. Script failures publish a <see cref="ScriptLogEntry"/> on the
/// <c>script-logs</c> DPS topic so operators see the diagnostic in the live tail.
///
/// <para>
/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA quality to
/// <see cref="OpcUaQuality.Bad"/> (carrying the last-known value), published <b>once per
/// transition</b> (Good→Bad) and gated on every declared dependency having arrived at least once
/// — so a broken script no longer serves its last Good value forever, and a fresh/respawned
/// multi-dep script does not flash Bad during deploy warm-up. Recovery to a Good value clears the
/// Bad state and is force-published even when the value equals the pre-failure value.
/// </para>
/// </summary>
public sealed class VirtualTagActor : ReceiveActor
{
@@ -41,6 +50,9 @@ public sealed class VirtualTagActor : ReceiveActor
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>Factory method to create Props for a VirtualTagActor.</summary>
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
@@ -122,27 +134,28 @@ public sealed class VirtualTagActor : ReceiveActor
catch (Exception ex)
{
_log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId);
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog("Error", $"evaluator threw: {ex.Message}");
OnEvaluationFailed($"evaluator threw: {ex.Message}", "Error", msg.TimestampUtc);
return;
}
if (!result.Success)
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog("Warning", result.Reason ?? "evaluator failure");
OnEvaluationFailed(result.Reason ?? "evaluator failure", "Warning", msg.TimestampUtc);
return;
}
// Skip no-change results. Real evaluator returns Ok(value); Null returns NoChange — both
// safe because Null never produces a fresh value.
// safe because Null never produces a fresh value. NoChange is "no fresh value", NOT a recovery,
// so it must stay above the dedup and must NOT clear _lastPublishedBad.
if (ReferenceEquals(result, VirtualTagEvalResult.NoChange))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
return;
}
if (_hasLastValue && Equals(_lastValue, result.Value))
// 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))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
return;
@@ -150,11 +163,41 @@ public sealed class VirtualTagActor : ReceiveActor
_hasLastValue = true;
_lastValue = result.Value;
_lastPublishedBad = false;
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId());
Context.Parent.Tell(evalResult);
}
/// <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
/// last-known value if any — once per Good→Bad transition, and only after every declared dependency
/// has arrived (the inputs-ready gate that suppresses the cold-start/respawn warm-up flash).</summary>
/// <param name="reason">The failure reason for the script-log entry.</param>
/// <param name="level">The script-log level ("Warning" for a Failure result, "Error" for a throw).</param>
/// <param name="timestampUtc">The triggering dependency change's timestamp, stamped on the Bad result.</param>
private void OnEvaluationFailed(string reason, string level, DateTime timestampUtc)
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog(level, reason);
if (_lastPublishedBad || !AllDependenciesArrived())
{
return;
}
_lastPublishedBad = true;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaQuality.Bad));
}
/// <summary>Inputs-ready gate (mirrors the dormant <c>VirtualTagEngine.AreInputsReady</c>): true once
/// every declared dependency ref has been seen at least once. Called once per failure — the ref list
/// is small, so no caching. A vacuously-true empty ref list means a no-dependency script degrades on
/// its first failure.</summary>
private bool AllDependenciesArrived() => _dependencyRefs.All(_dependencies.ContainsKey);
private void PublishLog(string level, string message)
{
var entry = new ScriptLogEntry(
@@ -4,6 +4,7 @@ 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.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -80,7 +81,10 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
entry.VirtualTagId.ShouldBe("vt-1");
}, duration: TimeSpan.FromMilliseconds(500));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
// 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the
// inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this
// replaces the old ExpectNoMsg, which encoded the stale-Good bug).
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
}
/// <summary>Test evaluator that sums integer dependency values.</summary>