fix(dcl): treat OPC UA BadResourceUnavailable as transient Bad; Bad+null keeps last-known value (ScadaBridge#112)

OtOpcUa's out-of-process script runner publishes 0x80040000 with a null value.
- OpcUaDataConnection: logs entry/recovery once per tag, names the symbolic
  status in read errors; never a disconnect (not in the reconnect status set).
- InstanceActor: a Bad update with no value is a quality-only transition
  (keep value, quality Bad, stream publish, no script/alarm/waiter re-eval),
  matching the connection-drop rule. Bad with a value is unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 04:38:05 -05:00
parent 533414a16d
commit 08fe5acfe4
5 changed files with 322 additions and 2 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
@@ -41,6 +42,14 @@ public class OpcUaDataConnection
private StaleTagMonitor? _staleMonitor;
private string? _heartbeatSubscriptionId;
/// <summary>
/// Tags whose latest notification is <c>BadResourceUnavailable</c> (ScadaBridge#112), so the
/// distinct "source temporarily unable to compute" log line fires once per entry and once per
/// recovery rather than per notification. Written from the SDK publish threads, hence
/// concurrent (DCL-019). Bounded by the subscribed tag set; cleared on (re)connect.
/// </summary>
private readonly ConcurrentDictionary<string, byte> _resourceUnavailableTags = new();
/// <summary>
/// Initializes a new instance of <see cref="OpcUaDataConnection"/>.
/// </summary>
@@ -99,6 +108,7 @@ public class OpcUaDataConnection
MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription);
_status = ConnectionHealth.Connecting;
_resourceUnavailableTags.Clear();
// Reconnect reuses this adapter instance (DataConnectionActor re-calls
// ConnectAsync on auto-reconnect): detach + dispose the previous client so
@@ -191,6 +201,7 @@ public class OpcUaDataConnection
(nodeId, value, timestamp, statusCode) =>
{
var quality = MapStatusCode(statusCode);
TrackResourceUnavailable(tagPath, statusCode);
callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
},
cancellationToken);
@@ -209,6 +220,7 @@ public class OpcUaDataConnection
(nodeId, value, timestamp, statusCode) =>
{
var quality = MapStatusCode(statusCode);
TrackResourceUnavailable(nodeId, statusCode);
callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
},
cancellationToken);
@@ -267,7 +279,7 @@ public class OpcUaDataConnection
var quality = MapStatusCode(statusCode);
if (quality == QualityCode.Bad)
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
return new ReadResult(false, null, DescribeBadRead(statusCode));
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
}
@@ -306,7 +318,7 @@ public class OpcUaDataConnection
var quality = MapStatusCode(outcome.StatusCode);
results[outcome.NodeId] = quality == QualityCode.Bad
? new ReadResult(false, null, $"OPC UA read returned bad status: 0x{outcome.StatusCode:X8}")
? new ReadResult(false, null, DescribeBadRead(outcome.StatusCode))
: new ReadResult(true,
new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)),
null);
@@ -461,6 +473,60 @@ public class OpcUaDataConnection
Disconnected?.Invoke();
}
/// <summary>
/// OPC UA <c>BadResourceUnavailable</c> — the code part (high 16 bits) of
/// <see cref="Opc.Ua.StatusCodes.BadResourceUnavailable"/>. OtOpcUa's out-of-process script
/// runner publishes it on script-computed nodes when the script worker died under the
/// evaluation or no worker is ready (ScadaBridge#112, lmxopcua#780). It is a per-node,
/// transient "cannot compute this value right now": a plain Bad quality on the tag — never a
/// connection fault, so it must not reach <c>RaiseDisconnected</c> or the connection-level
/// status set the DataConnectionActor reconnects on.
/// </summary>
internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable;
/// <summary>True when <paramref name="statusCode"/>'s code part is BadResourceUnavailable
/// (the low 16 bits are info bits and do not change the meaning).</summary>
internal static bool IsResourceUnavailable(uint statusCode) =>
(statusCode & 0xFFFF0000u) == BadResourceUnavailableCode;
/// <summary>Read-failure text naming the symbolic status, e.g.
/// <c>0x80040000 BadResourceUnavailable</c>, so a script/diagnostic reader can tell a
/// transient source-side condition from a configuration or security error.</summary>
internal static string DescribeBadRead(uint statusCode)
{
var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u);
var text = string.IsNullOrEmpty(name)
? $"OPC UA read returned bad status: 0x{statusCode:X8}"
: $"OPC UA read returned bad status: 0x{statusCode:X8} {name}";
return IsResourceUnavailable(statusCode)
? text + " (source node temporarily unable to compute the value)"
: text;
}
/// <summary>
/// Logs the entry into / recovery from <c>BadResourceUnavailable</c> once per transition per
/// tag. Hot path: a tag that is not and was not unavailable costs one status compare and an
/// empty-dictionary check.
/// </summary>
private void TrackResourceUnavailable(string tagPath, uint statusCode)
{
if (IsResourceUnavailable(statusCode))
{
if (_resourceUnavailableTags.TryAdd(tagPath, 0))
_logger.LogInformation(
"OPC UA source {Endpoint} reports BadResourceUnavailable (0x{Status:X8}) for {TagPath} — "
+ "the source node is temporarily unable to compute this value; tag quality is Bad, "
+ "last-known value kept, connection unaffected",
_endpointUrl, statusCode, tagPath);
}
else if (!_resourceUnavailableTags.IsEmpty && _resourceUnavailableTags.TryRemove(tagPath, out _))
{
_logger.LogInformation(
"OPC UA source {Endpoint} recovered {TagPath} from BadResourceUnavailable (now 0x{Status:X8})",
_endpointUrl, tagPath, statusCode);
}
}
/// <summary>
/// Maps OPC UA StatusCode to QualityCode.
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.