feat(opcua): IOpcUaAddressSpaceSink.WriteStatuses — status-only batch write retaining the node value (03/S15)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user