fix(opcuaserver): chunked, per-item-isolated WriteValues with typed partial-failure outcome

This commit is contained in:
Joseph Doherty
2026-08-15 12:32:44 -04:00
parent 8ef02506f7
commit 6bf1fb3754
7 changed files with 468 additions and 82 deletions
@@ -25,8 +25,13 @@ public interface IOpcUaAddressSpaceSink
/// <summary>Write a batch of Variable values. Semantically identical to calling
/// <see cref="WriteValue"/> per item; an SDK-backed sink overrides this to take its internal
/// lock ONCE per batch instead of once per value (the hot-path win). The default implementation
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.</summary>
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.
/// <para>A batching implementation MUST isolate per item — one bad item may not drop the tail of the
/// batch — and report the aggregate as a <see cref="ValueWriteBatchException"/>; see that type for why.
/// The default loop above predates the contract and is only used by fakes / the no-op sink.</para></summary>
/// <param name="values">The value 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 WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
@@ -185,6 +190,30 @@ public interface IOpcUaAddressSpaceSink
public readonly record struct ValueWrite(
string NodeId, object? Value, 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
/// updates its last-value cache BEFORE raising, so a dropped item is never re-raised until that tag's value
/// changes again — the node would serve a stale value indefinitely. Hence the sink isolates per item and
/// reports the aggregate here rather than letting the first failure escape.</remarks>
/// <param name="firstFailedNodeId">The NodeId of the first item that failed (empty when that item's own
/// NodeId was null/empty — the validation failure this type most often reports).</param>
/// <param name="failedCount">How many items of the batch failed.</param>
/// <param name="succeededCount">How many items of the batch were applied.</param>
/// <param name="firstFailure">The exception the first failing item produced; the inner exception.</param>
public sealed class ValueWriteBatchException(string firstFailedNodeId, int failedCount, int succeededCount, Exception firstFailure)
: Exception($"WriteValues batch: {failedCount} of {failedCount + succeededCount} items failed (first: '{firstFailedNodeId}')", firstFailure)
{
/// <summary>Gets the NodeId of the first item that failed.</summary>
public string FirstFailedNodeId { get; } = firstFailedNodeId;
/// <summary>Gets the number of items of the batch that failed.</summary>
public int FailedCount { get; } = failedCount;
/// <summary>Gets the number of items of the batch that were successfully applied.</summary>
public int SucceededCount { get; } = succeededCount;
}
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
/// integration tests without a real SDK behind them.</summary>
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink