feat(opcua): IOpcUaAddressSpaceSink.WriteStatuses — status-only batch write retaining the node value (03/S15)

This commit is contained in:
Joseph Doherty
2026-08-20 10:00:47 -04:00
parent 6874a562f3
commit ec5b48bfcd
7 changed files with 398 additions and 1 deletions
@@ -32,6 +32,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// (actors inject THIS wrapper, not the inner SdkAddressSpaceSink) — same trap as WireAlarmNotifiers above.
public void WriteValues(IReadOnlyList<ValueWrite> values) => _inner.WriteValues(values);
/// <inheritdoc />
// Forward the status-only batch (03/S15). Unforwarded, the interface's DEFAULT runs here instead —
// and that default writes a NULL value, so a comms-loss annotation would erase the last known value
// of every node on the driver rather than retaining it, on every driver-role host (actors inject
// THIS wrapper, not the inner SdkAddressSpaceSink). Same trap as WriteValues / WireAlarmNotifiers
// above, except this one degrades to wrong data rather than to slow.
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses) => _inner.WriteStatuses(statuses);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
@@ -42,6 +42,31 @@ public interface IOpcUaAddressSpaceSink
}
}
/// <summary>Batched <b>status-only</b> write: each node keeps its CURRENT value and only its
/// StatusCode + SourceTimestamp change. This is the comms-loss annotation for value variables
/// (archreview 03/S15) — a driver that has stopped communicating leaves the node holding its last
/// known value (the Part 8 convention SCADA clients expect) under a
/// <see cref="OpcUaStatus.BadNoCommunication"/> status, rather than nulling it.
/// <para>A sink that HOLDS the node's value must retain it — the SDK-backed sink does, because the
/// node itself is where the value lives. This DEFAULT implementation cannot: the host holds no value
/// cache (v3's single-source design), so it degrades to <see cref="WriteValue"/> with a null value,
/// which is the honest statement for a value-less sink and keeps every fake compiling unchanged.</para>
/// <para>Same per-item isolation contract as <see cref="WriteValues"/>. An unknown or
/// unmaterialised NodeId is a <b>no-op, never a create</b>: a status carries nothing to create a node
/// from, and the caller's index can legitimately name a node a surgical remove has just dropped.</para></summary>
/// <param name="statuses">The status writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">A batching implementation throws this when some — but not
/// necessarily all — items failed; every other item has been applied.</exception>
void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
{
ArgumentNullException.ThrowIfNull(statuses);
for (var i = 0; i < statuses.Count; i++)
{
var s = statuses[i];
WriteValue(s.NodeId, null, s.StatusCode, s.SourceTimestampUtc, s.Realm);
}
}
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
/// this projects the whole <see cref="AlarmConditionSnapshot"/>
@@ -190,6 +215,16 @@ public interface IOpcUaAddressSpaceSink
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// <summary>One status-only write within a <see cref="IOpcUaAddressSpaceSink.WriteStatuses"/> batch: the
/// node's current VALUE is retained and only <paramref name="StatusCode"/> +
/// <paramref name="SourceTimestampUtc"/> change.</summary>
/// <param name="NodeId">The OPC UA node id of the variable, within <paramref name="Realm"/>.</param>
/// <param name="StatusCode">The full status code, sub-code included, passed to the SDK verbatim (#519).</param>
/// <param name="SourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="Realm">Which of the two v3 namespaces <paramref name="NodeId"/> lives in.</param>
public readonly record struct StatusWrite(
string NodeId, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// <summary>Aggregate outcome of a partially-failed <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch:
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.</summary>
/// <remarks>A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
@@ -640,6 +640,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);