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
@@ -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));
}