Merge branch 'fix/r3-02-stale-good-family' — R3-02: one quality contract at four seams — comms loss marks raw+UNS value variables BadNoCommunication (WriteStatuses sink member, recovery audit), Calculation + VT hold on Bad / worst-of sub-code / (value,status) dedup, MQTT initial push + ConnectionLost stale, Sparkplug birth replay (archreview 03/S15, 05/STAB-19, 05/STAB-18, 02/S14)
This commit is contained in:
@@ -59,7 +59,34 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
|
||||
/// evaluation-time timestamp — bare values carry nothing better. The runtime calls the snapshot
|
||||
/// overload instead (archreview 02/S14); this one remains for callers that genuinely hold only
|
||||
/// values.
|
||||
/// </remarks>
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependencies);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var snapshots = new Dictionary<string, DataValueSnapshot>(dependencies.Count, StringComparer.Ordinal);
|
||||
foreach (var kv in dependencies)
|
||||
snapshots[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, nowUtc, nowUtc);
|
||||
|
||||
return Evaluate(virtualTagId, expression, snapshots);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Overrides the interface's default (which projects to values and loses the status codes): the
|
||||
/// snapshots pass <b>through</b> as the script's read cache, so
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> and <c>.SourceTimestampUtc</c> are the input's own rather
|
||||
/// than a fabricated <c>Good</c> stamped at evaluation time (archreview 02/S14). The virtual-tag
|
||||
/// actor holds evaluation entirely while any input is Bad, so what a script observes here is Good
|
||||
/// or Uncertain.
|
||||
/// </remarks>
|
||||
public VirtualTagEvalResult Evaluate(
|
||||
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
@@ -71,12 +98,14 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
// so the script returns null — Ok(null) here too.
|
||||
if (PassthroughScript.TryMatch(expression, out var passthroughRef))
|
||||
{
|
||||
return dependencies.TryGetValue(passthroughRef, out var ptValue)
|
||||
? VirtualTagEvalResult.Ok(ptValue)
|
||||
// The VALUE only — the caller stamps the result with the worst input status, which for a
|
||||
// single-dependency passthrough IS this input's status.
|
||||
return dependencies.TryGetValue(passthroughRef, out var ptSnapshot)
|
||||
? VirtualTagEvalResult.Ok(ptSnapshot?.Value)
|
||||
: VirtualTagEvalResult.Ok(null);
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
var readCache = dependencies;
|
||||
// Per-evaluation script logger: bind both ScriptId and VirtualTagId from the virtual-tag id
|
||||
// (in the live path the script id equals the virtual-tag id) so the Script-log page can
|
||||
// attribute each line. EquipmentId stays unbound for now.
|
||||
@@ -162,21 +191,6 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
/// <inheritdoc />
|
||||
public void ClearCompiledScripts() => _cache.Clear();
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
|
||||
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
|
||||
// values stay null; the script can null-check via GetTag(path).Value.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps)
|
||||
{
|
||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the evaluator and cleans up all cached script evaluators.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -649,6 +649,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply a batch of <b>status-only</b> writes: each node keeps its CURRENT value and only its
|
||||
/// StatusCode + Timestamp change (archreview 03/S15 — the comms-loss annotation for value variables).
|
||||
/// Same shape as <see cref="WriteValues"/>: an outside-the-lock validate/resolve pre-pass, then
|
||||
/// <see cref="MaxWritesPerLockAcquisition"/>-sized chunks under one <c>Lock</c> each, per-item isolation,
|
||||
/// and a <see cref="ValueWriteBatchException"/> aggregate.
|
||||
/// <para><b>It never creates a node.</b> A status carries nothing to create one from, and the caller's
|
||||
/// per-driver ref index can legitimately name a node a surgical remove has just dropped — so an unknown
|
||||
/// id is skipped, not materialised, and is not counted as a failure either (it is an expected race, not
|
||||
/// a fault).</para></summary>
|
||||
/// <param name="statuses">The status writes to apply, in order.</param>
|
||||
/// <exception cref="ValueWriteBatchException">When one or more items failed validation or threw. Every
|
||||
/// other item HAS been applied.</exception>
|
||||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statuses);
|
||||
EnsureAddressSpaceCreated();
|
||||
if (statuses.Count == 0) return;
|
||||
|
||||
var resolved = new ResolvedWrite[statuses.Count];
|
||||
string? firstFailedNodeId = null;
|
||||
Exception? firstFailure = null;
|
||||
var failed = 0;
|
||||
|
||||
for (var i = 0; i < statuses.Count; i++)
|
||||
{
|
||||
var nodeId = statuses[i].NodeId;
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
{
|
||||
failed++;
|
||||
firstFailedNodeId ??= nodeId ?? string.Empty;
|
||||
firstFailure ??= new ArgumentException(
|
||||
$"WriteStatuses item {i} has a null or empty NodeId.", nameof(statuses));
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[i] = new ResolvedWrite(
|
||||
nodeId, NamespaceIndexForRealm(statuses[i].Realm), MapKey(statuses[i].Realm, nodeId), IsValid: true);
|
||||
}
|
||||
|
||||
var succeeded = 0;
|
||||
var skipped = 0;
|
||||
for (var start = 0; start < statuses.Count; start += MaxWritesPerLockAcquisition)
|
||||
{
|
||||
var end = Math.Min(start + MaxWritesPerLockAcquisition, statuses.Count);
|
||||
lock (Lock)
|
||||
{
|
||||
for (var i = start; i < end; i++)
|
||||
{
|
||||
if (!resolved[i].IsValid) continue;
|
||||
var s = statuses[i];
|
||||
try
|
||||
{
|
||||
if (WriteStatusLocked(resolved[i].Key, s.StatusCode, s.SourceTimestampUtc)) succeeded++;
|
||||
else skipped++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
firstFailedNodeId ??= s.NodeId;
|
||||
firstFailure ??= ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0)
|
||||
{
|
||||
// Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver
|
||||
// whose nodes were all just removed would otherwise log once per ref. This manager carries no
|
||||
// ILogger, so log via the SDK's static trace (see ServeNodeAsync).
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogDebug(
|
||||
"OtOpcUaNodeManager: WriteStatuses skipped {0} of {1} item(s) — node not materialised.",
|
||||
skipped,
|
||||
statuses.Count);
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
if (failed > 0)
|
||||
{
|
||||
throw new ValueWriteBatchException(
|
||||
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The body of a single status-only write. <b>Caller MUST hold <c>Lock</c></b>.</summary>
|
||||
/// <param name="key">The realm-qualified map key.</param>
|
||||
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the annotation in UTC.</param>
|
||||
/// <returns><see langword="true"/> when the node existed and was stamped; <see langword="false"/> when
|
||||
/// no such node is materialised (skipped — deliberately not a create; see <see cref="WriteStatuses"/>).</returns>
|
||||
private bool WriteStatusLocked(RealmKey key, uint statusCode, DateTime sourceTimestampUtc)
|
||||
{
|
||||
if (!_variables.TryGetValue(key, out var variable)) return false;
|
||||
|
||||
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
|
||||
// Bad status (OPC UA Part 8's last-known-value convention).
|
||||
variable.StatusCode = new StatusCode(statusCode);
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
|
||||
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
|
||||
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
|
||||
|
||||
@@ -31,6 +31,13 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
=> _nodeManager.WriteValues(values);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Overrides the interface's default on purpose, and here the default is not merely slower but
|
||||
/// <b>wrong</b>: it writes a null value (a value-less sink has nothing else to write), which would erase
|
||||
/// the last known value this annotation exists to preserve. The manager's own entry point retains it.</remarks>
|
||||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
=> _nodeManager.WriteStatuses(statuses);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
@@ -91,6 +91,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// connect work, so it should not run more often than an ordinary reconnect attempt.</summary>
|
||||
public static readonly TimeSpan DefaultReprovisionBackoff = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs still
|
||||
/// latched <see cref="OpcUaStatus.BadNoCommunication"/>. Sized so an ordinary poll driver has had
|
||||
/// several poll cycles — and a slow one at least one — to re-publish before anything is said; the
|
||||
/// audit's job is to name a driver that will NEVER re-publish, not to race a slow first poll.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCommsRecoveryAuditDelay = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Cache key used when an artifact carries no cluster scoping (single-cluster or unscoped
|
||||
/// deployments). A literal rather than an empty string so the row is visibly deliberate when
|
||||
@@ -426,6 +434,27 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// <see cref="DefaultApplyWedgeTimeout"/>).</summary>
|
||||
private readonly TimeSpan _applyWedgeTimeout;
|
||||
|
||||
/// <summary>How long after a driver reconnects the 03/S15 recovery audit waits before reporting refs
|
||||
/// still latched <see cref="OpcUaStatus.BadNoCommunication"/> (see <see cref="CommsRecoveryAudit"/>).</summary>
|
||||
private readonly TimeSpan _commsRecoveryAuditDelay;
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>03/S15</b> — RawPaths this host has stamped <c>BadNoCommunication</c> on the value
|
||||
/// nodes of, per driver, and has not yet seen a value for. Set by
|
||||
/// <see cref="FanCommsLossToValueNodes"/>, pruned by <see cref="ForwardBatchToMux"/> as each ref
|
||||
/// re-publishes, and read once by <see cref="CommsRecoveryAudit"/> after the driver reconnects.
|
||||
/// <para><b>It is a tripwire, not a mechanism.</b> Nothing depends on it being correct for the data
|
||||
/// plane to behave: the host publishes nothing Good on reconnect either way. What it buys is that a
|
||||
/// driver which reconnects and then fails to re-publish — the pre-STAB-18 MQTT shape, and what any
|
||||
/// future push driver without an initial push will do — produces a Warning and a metered count
|
||||
/// instead of a subscription that silently reports Bad forever.</para>
|
||||
/// <para>Keyed by RawPath, so a raw node and its UNS mirrors count once: the operator is being told
|
||||
/// how many <i>tags</i> did not come back, not how many address-space nodes they project onto. The
|
||||
/// entry is removed as soon as it empties, so the steady-state cost in
|
||||
/// <see cref="ForwardBatchToMux"/> is one dictionary miss per burst.</para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, HashSet<string>> _commsLatched = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Per-driver rate limit for the supervision-reprovision hook (see
|
||||
/// <see cref="DefaultReprovisionBackoff"/>).</summary>
|
||||
private readonly TimeSpan _reprovisionBackoff;
|
||||
@@ -483,6 +512,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
|
||||
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
|
||||
|
||||
/// <summary>Self-sent by the 03/S15 audit timer, <see cref="_commsRecoveryAuditDelay"/> after a driver
|
||||
/// reconnected: report any of its refs still latched <c>BadNoCommunication</c>. See
|
||||
/// <see cref="HandleCommsRecoveryAudit"/>.</summary>
|
||||
/// <param name="DriverInstanceId">The driver whose recovery is being audited.</param>
|
||||
public sealed record CommsRecoveryAudit(string DriverInstanceId);
|
||||
|
||||
private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed)
|
||||
{
|
||||
// Convenience accessors for sites that don't need the full spec.
|
||||
@@ -548,6 +583,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// VirtualTag + ScriptedAlarm hosts. Defaults to null (admin-only nodes / tests skip the emit).</param>
|
||||
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
|
||||
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>.</param>
|
||||
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
|
||||
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
|
||||
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; tests shorten it.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
@@ -577,7 +615,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
IHistorizationRoleView? historizationRoleView = null,
|
||||
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
||||
TimeSpan? applyWedgeTimeout = null,
|
||||
TimeSpan? reprovisionBackoff = null) =>
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -589,7 +628,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff));
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -648,6 +687,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// <param name="applyWedgeTimeout">How long the host may sit in <see cref="Applying"/> before the
|
||||
/// wedge watchdog logs a Warning. Defaults to <see cref="DefaultApplyWedgeTimeout"/>; a test shortens
|
||||
/// it so the watchdog is observable in a test's lifetime.</param>
|
||||
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
|
||||
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
|
||||
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; a test shortens it for the same reason.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -676,10 +718,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
IHistorizationRoleView? historizationRoleView = null,
|
||||
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
|
||||
TimeSpan? applyWedgeTimeout = null,
|
||||
TimeSpan? reprovisionBackoff = null)
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null)
|
||||
{
|
||||
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
|
||||
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
|
||||
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay;
|
||||
_historizationRoleView = historizationRoleView;
|
||||
_sharesHistorizationScopeWithLocal = sharesHistorizationScopeWithLocal;
|
||||
_telemetryHub = telemetryHub;
|
||||
@@ -1110,6 +1154,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -1167,6 +1212,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -1209,6 +1255,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// One envelope per burst; the mux filters per item (it registered a batch receive in Task 9).
|
||||
_dependencyMux?.Tell(msg);
|
||||
|
||||
// 03/S15 recovery audit: clear whatever this burst re-published from the driver's Bad latch. Guarded
|
||||
// by ONE dictionary probe per burst — with no outage outstanding (the steady state) this is a single
|
||||
// miss, not per-item work.
|
||||
if (_commsLatched.ContainsKey(msg.DriverInstanceId))
|
||||
{
|
||||
for (var i = 0; i < msg.Items.Count; i++)
|
||||
TrackCommsLatchPrune(msg.DriverInstanceId, msg.Items[i].FullReference);
|
||||
}
|
||||
|
||||
if (_opcUaPublishActor is null) return;
|
||||
|
||||
// Indexed for-loop, not foreach: IReadOnlyList<AttributeValueItem> enumerates through an interface
|
||||
@@ -1285,13 +1340,34 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// the source had died. <see cref="FanCommsLossToDependencyMux"/> closes that by pushing a
|
||||
/// synthetic Bad for every value ref the driver owns.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Archreview 03/S15 — and the EXTERNAL data plane, which the two layers above still left
|
||||
/// out.</b> Conditions and internal consumers learned of the outage; the driver's raw value
|
||||
/// variables and their UNS mirrors — what an OPC UA client actually subscribes to — did not, so
|
||||
/// they served the last value at <c>Good</c> for the whole outage.
|
||||
/// <see cref="FanCommsLossToValueNodes"/> closes that with one status-only batch that keeps each
|
||||
/// node's value and changes only its status. All three fan-outs run on the disconnect edge only;
|
||||
/// the reconnect edge writes nothing to values (recovery is the driver's own re-publish) and
|
||||
/// re-annotates conditions Good, which is a statement about the LINK, not about any tag's value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
|
||||
{
|
||||
// #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
|
||||
// publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
|
||||
// return for exactly that reason.
|
||||
if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId);
|
||||
if (!msg.Connected)
|
||||
{
|
||||
FanCommsLossToDependencyMux(msg.DriverInstanceId);
|
||||
// 03/S15: and the EXTERNAL data plane, which #481 stopped one seam short of.
|
||||
FanCommsLossToValueNodes(msg.DriverInstanceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 03/S15: nothing Good is published here — instead, check LATER whether the driver republished
|
||||
// what this host marked Bad. See HandleCommsRecoveryAudit.
|
||||
TrackCommsLatchArmAudit(msg.DriverInstanceId);
|
||||
}
|
||||
|
||||
if (_opcUaPublishActor is null) return;
|
||||
|
||||
@@ -1323,6 +1399,160 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Archreview <b>03/S15</b> — the value-node half of the comms-loss fan-out: stamp
|
||||
/// <see cref="OpcUaStatus.BadNoCommunication"/> on every raw NodeId <b>and</b> every referencing UNS
|
||||
/// NodeId the just-disconnected driver owns, as ONE status batch through the same publish actor the
|
||||
/// value fan-out uses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why it was missing.</b> #481 built the per-driver ref index and wired comms loss to the
|
||||
/// internal consumers (the mux) and to Part 9 conditions, and stopped one seam short of the
|
||||
/// external OPC UA data plane. A client subscribed to a value node therefore saw the last value
|
||||
/// at <b>Good</b> quality for the whole outage, for every driver whose failure shape is "the
|
||||
/// reader stops producing" rather than "publish a Bad snapshot per tag". (Modbus does the
|
||||
/// latter, quickly, which is why the gap looked smaller than it was — a coalesced-read throw, a
|
||||
/// dropped session on a push driver, or simply a driver whose last act was a Good publish all
|
||||
/// held Good indefinitely.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Status-only, so the VALUE is retained.</b> This actor holds no value cache — v3's
|
||||
/// single-source design deliberately removed the independent buffer — so only the node can keep
|
||||
/// the last known value, which is what
|
||||
/// <see cref="OpcUa.OpcUaPublishActor.AttributeStatusBatchUpdate"/> exists for. A Bad node still
|
||||
/// carrying its last reading is OPC UA Part 8's convention and is what a SCADA consumer expects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing is written on reconnect.</b> Publishing Good would assert a quality this actor has
|
||||
/// not observed; recovery is the driver's own re-publish (see
|
||||
/// <see cref="FanCommsLossToDependencyMux"/>'s remark on the detach that makes the poll engine
|
||||
/// force-raise, and MQTT's initial push).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ungated by redundancy role, like the condition annotation: a Secondary keeps its address
|
||||
/// space honest for failover. Re-stamping an already-Bad node is harmless — the SDK's
|
||||
/// <c>StatusValue</c> trigger dedups an unchanged status at the monitored item.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="driverInstanceId">The driver child that just reported comms loss.</param>
|
||||
private void FanCommsLossToValueNodes(string driverInstanceId)
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
if (!_valueRefsByDriver.TryGetValue(driverInstanceId, out var entries) || entries.Count == 0) return;
|
||||
|
||||
var ts = DateTime.UtcNow;
|
||||
var writes = new List<StatusWrite>(entries.Count);
|
||||
foreach (var (_, nodes) in entries)
|
||||
{
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
writes.Add(new StatusWrite(n.NodeId, OpcUaStatus.BadNoCommunication, ts, n.Realm));
|
||||
}
|
||||
}
|
||||
|
||||
if (writes.Count == 0) return;
|
||||
|
||||
_opcUaPublishActor.Tell(new OpcUa.OpcUaPublishActor.AttributeStatusBatchUpdate(writes));
|
||||
TrackCommsLatchSet(driverInstanceId, entries);
|
||||
|
||||
_log.Info(
|
||||
"DriverHost {Node}: driver {Driver} disconnected — {Count} value node(s) marked BadNoCommunication (value retained)",
|
||||
_localNode, driverInstanceId, writes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every RawPath just latched <c>BadNoCommunication</c> for <paramref name="driverInstanceId"/>
|
||||
/// (03/S15's recovery audit — see <see cref="_commsLatched"/>). Replaces any previous latch wholesale:
|
||||
/// a second outage re-arms from the current ref set rather than accumulating refs a redeploy removed.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver that just went comms-lost.</param>
|
||||
/// <param name="entries">Its value refs, from the per-driver index.</param>
|
||||
private void TrackCommsLatchSet(
|
||||
string driverInstanceId,
|
||||
List<(string RawPath, HashSet<NodeRealmRef> Nodes)> entries)
|
||||
{
|
||||
var latched = new HashSet<string>(entries.Count, StringComparer.Ordinal);
|
||||
foreach (var (rawPath, _) in entries) latched.Add(rawPath);
|
||||
_commsLatched[driverInstanceId] = latched;
|
||||
|
||||
// A previous reconnect's audit is now meaningless — this driver is down again.
|
||||
Timers.Cancel(CommsAuditTimerKey(driverInstanceId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears <paramref name="fullReference"/> from <paramref name="driverInstanceId"/>'s latch, and drops
|
||||
/// the driver's entry once it empties. Called per routed value item, so the steady-state cost with no
|
||||
/// latch outstanding is one dictionary miss per burst — not per item.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The publishing driver.</param>
|
||||
/// <param name="fullReference">The RawPath that just published a value.</param>
|
||||
private void TrackCommsLatchPrune(string driverInstanceId, string fullReference)
|
||||
{
|
||||
if (!_commsLatched.TryGetValue(driverInstanceId, out var latched)) return;
|
||||
if (!latched.Remove(fullReference)) return;
|
||||
if (latched.Count == 0) _commsLatched.Remove(driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the recovery audit for a driver that has just reconnected: after
|
||||
/// <see cref="_commsRecoveryAuditDelay"/>, <see cref="HandleCommsRecoveryAudit"/> reports whatever is
|
||||
/// still latched. Nothing is armed when the driver has no latch — it never went Bad here.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver that just reconnected.</param>
|
||||
private void TrackCommsLatchArmAudit(string driverInstanceId)
|
||||
{
|
||||
if (!_commsLatched.ContainsKey(driverInstanceId)) return;
|
||||
Timers.StartSingleTimer(
|
||||
CommsAuditTimerKey(driverInstanceId),
|
||||
new CommsRecoveryAudit(driverInstanceId),
|
||||
_commsRecoveryAuditDelay);
|
||||
}
|
||||
|
||||
private static string CommsAuditTimerKey(string driverInstanceId) => $"comms-audit:{driverInstanceId}";
|
||||
|
||||
/// <summary>
|
||||
/// The 03/S15 tripwire firing: refs this host latched <c>BadNoCommunication</c> that the driver has
|
||||
/// still not re-published <see cref="_commsRecoveryAuditDelay"/> after it reconnected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It reports, it does not repair.</b> Writing Good here would assert a quality nothing has
|
||||
/// observed — the very thing this whole path refuses to do. What the operator gets instead is a
|
||||
/// named driver and a count, plus the <c>otopcua.driver.comms_latched_refs</c> counter, which is
|
||||
/// the operator-visible signature of a driver that reconnects without re-publishing (the
|
||||
/// pre-STAB-18 MQTT shape, or any future push driver that forgets its initial push).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The latch is cleared afterwards either way: it has been reported, and leaving it would make
|
||||
/// the next outage's audit report refs from this one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="msg">Names the driver whose audit is due.</param>
|
||||
private void HandleCommsRecoveryAudit(CommsRecoveryAudit msg)
|
||||
{
|
||||
if (!_commsLatched.TryGetValue(msg.DriverInstanceId, out var latched) || latched.Count == 0)
|
||||
{
|
||||
_commsLatched.Remove(msg.DriverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
var driverType = _children.TryGetValue(msg.DriverInstanceId, out var child) ? child.DriverType : "unknown";
|
||||
OtOpcUaTelemetry.DriverCommsLatchedRefs.Add(
|
||||
latched.Count,
|
||||
new KeyValuePair<string, object?>("driver", msg.DriverInstanceId),
|
||||
new KeyValuePair<string, object?>("driver_type", driverType));
|
||||
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: driver {Driver} ({Type}) — {Count} ref(s) still BadNoCommunication {Delay} after reconnect; "
|
||||
+ "the driver did not re-publish them (missing initial push, or a value-only dedup swallowed an unchanged value). "
|
||||
+ "First: {First}",
|
||||
_localNode, msg.DriverInstanceId, driverType, latched.Count, _commsRecoveryAuditDelay,
|
||||
latched.OrderBy(r => r, StringComparer.Ordinal).First());
|
||||
|
||||
_commsLatched.Remove(msg.DriverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #481 (Layer 4) — push a synthetic <see cref="OpcUaStatus.BadNoCommunication"/> into the dependency
|
||||
/// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
|
||||
@@ -1778,6 +2008,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
|
||||
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
|
||||
Receive<CommsRecoveryAudit>(_ => { });
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
|
||||
@@ -1423,6 +1423,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
// Archreview 03/S15 — announce comms loss on the way out. Every OTHER route into Reconnecting
|
||||
// (watchdog trip, failed initial connect, Force Reconnect) announces it from the Reconnecting
|
||||
// entry; stopping did not, so an operator's Restart and a supervision respawn tore this child down
|
||||
// silently and its value nodes went on serving their last value at Good across the gap. The fresh
|
||||
// child's re-subscribe + initial push (03/S16) is what clears them again; on a deploy that REMOVES
|
||||
// the driver the host has already rebuilt its ref index without it, so the Tell finds nothing.
|
||||
//
|
||||
// Gated on having actually reached Connected: a child that never connected leaves its nodes at
|
||||
// BadWaitingForInitialData, which says something more precise than "communication lost", and
|
||||
// overwriting it would claim an outage that never happened.
|
||||
if (_connectedSinceUtc != default)
|
||||
{
|
||||
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
|
||||
}
|
||||
|
||||
DetachSubscription();
|
||||
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
|
||||
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
|
||||
|
||||
@@ -56,6 +56,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// their realm each (<see cref="ValueWrite"/>); status codes ride verbatim (#519).</summary>
|
||||
public sealed record AttributeValueBatchUpdate(IReadOnlyList<ValueWrite> Items);
|
||||
|
||||
/// <summary>Status-only batch: every named node keeps its CURRENT value and only its status +
|
||||
/// timestamp change (archreview 03/S15). The comms-loss fan-out sends one of these covering every
|
||||
/// NodeId a disconnected driver owns — the raw node and each referencing UNS node — so a client sees
|
||||
/// <see cref="OpcUaStatus.BadNoCommunication"/> over the last known value instead of a stale Good.
|
||||
/// <para>Deliberately a separate message from <see cref="AttributeValueBatchUpdate"/> rather than a
|
||||
/// flag on it: the host holds no value cache (v3's single-source design), so it has nothing to put in
|
||||
/// a <see cref="ValueWrite"/>, and only the node knows what value to retain.</para></summary>
|
||||
public sealed record AttributeStatusBatchUpdate(IReadOnlyList<StatusWrite> Items);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
||||
@@ -292,6 +301,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
|
||||
Receive<AttributeStatusBatchUpdate>(HandleAttributeStatusBatchUpdate);
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
@@ -368,6 +378,36 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Routes a status-only batch to <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> — the
|
||||
/// comms-loss annotation (03/S15). Same shape as <see cref="HandleAttributeBatchUpdate"/>: metered per
|
||||
/// ITEM (batching is a transport change, so dashboards must not drop by the batch factor), a
|
||||
/// <see cref="ValueWriteBatchException"/> is a partial failure that still counts what landed and names
|
||||
/// the offender, and any other throw is swallowed so a sink fault cannot restart the publish actor and
|
||||
/// drop live traffic. Metered under <c>kind:"status"</c> so a status annotation is separable from a
|
||||
/// value write on the same counter.</summary>
|
||||
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteStatuses(msg.Items);
|
||||
Interlocked.Add(ref _writes, msg.Items.Count);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "status"));
|
||||
}
|
||||
catch (ValueWriteBatchException ex)
|
||||
{
|
||||
_log.Warning(ex,
|
||||
"OpcUaPublish: sink.WriteStatuses partially failed for a {Count}-item batch — {Failed} failed " +
|
||||
"(first: {Node}), {Succeeded} applied",
|
||||
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
|
||||
Interlocked.Add(ref _writes, ex.SucceededCount);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "status"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteStatuses threw for a {Count}-item batch", msg.Items.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAlarmUpdate(AlarmStateUpdate msg)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -66,10 +66,23 @@ public sealed class DependencyConsumerMuxAdapter : ReceiveActor
|
||||
_log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards one dependency value to the consumer, <b>status code included</b> (archreview
|
||||
/// 05/STAB-19).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This used to pass a hard-coded <c>0u</c> behind a comment saying quality was stripped at the
|
||||
/// mux boundary. That was true when the adapter was written and stopped being true at #478/#519 —
|
||||
/// <see cref="VirtualTagActor.DependencyValueChanged"/> has carried the source driver's full status
|
||||
/// code, sub-code and all, ever since. The stale comment outliving the fact is what kept a
|
||||
/// comms-lost input arriving at the Calculation driver as <i>arrived, Good, null</i>:
|
||||
/// indistinguishable from a genuine Good null, so the warm-up gate was satisfied by a value that
|
||||
/// never arrived and the calc output stayed Good over a dead source.
|
||||
/// </remarks>
|
||||
/// <param name="msg">The dependency value, as the mux delivered it.</param>
|
||||
private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
|
||||
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
|
||||
_consumer.OnDependencyValue(msg.TagId, msg.Value, msg.StatusCode, msg.TimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>The mux's batched delivery: one message carrying this adapter's whole matched slice of a
|
||||
|
||||
@@ -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,55 @@ 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)
|
||||
{
|
||||
if (!AllDependenciesArrived()) return;
|
||||
if (_hasPublished && _lastPublishedStatus == worstInputStatus) return;
|
||||
|
||||
// Metered BELOW both guards, so the counter measures tags that actually REPORT Bad inputs. Above
|
||||
// them it also counted every warm-up trigger — a dependency that has simply not arrived yet — which
|
||||
// is ordinary cold start, and burying the real signal in it is how a counter stops being trusted.
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "inputs-bad"));
|
||||
|
||||
_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 +355,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