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