fix(runtime): comms loss marks raw + UNS value nodes BadNoCommunication, value retained (archreview 03/S15)

This commit is contained in:
Joseph Doherty
2026-08-20 10:07:12 -04:00
parent 04019e9414
commit 97f4ea0c9e
4 changed files with 216 additions and 2 deletions
@@ -1244,13 +1244,28 @@ 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);
}
if (_opcUaPublishActor is null) return;
@@ -1282,6 +1297,67 @@ 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));
_log.Info(
"DriverHost {Node}: driver {Driver} disconnected — {Count} value node(s) marked BadNoCommunication (value retained)",
_localNode, driverInstanceId, writes.Count);
}
/// <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