perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:04 -04:00
parent ee193cd2bb
commit d15c5f02ea
25 changed files with 3131 additions and 324 deletions
@@ -19,7 +19,42 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
public class RealOpcUaClient : IOpcUaClient
{
private ISession? _session;
private Subscription? _subscription;
/// <summary>
/// One data shard = one OPC UA Subscription holding at most
/// <see cref="OpcUaConnectionOptions.MaxMonitoredItemsPerSubscription"/> monitored
/// items. The count is tracked here rather than read back off the SDK object so
/// placement decisions are made from state this class owns (and mutates only under
/// <see cref="_applyGate"/>).
/// </summary>
private sealed class DataShard(Subscription subscription)
{
public Subscription Subscription { get; } = subscription;
public int ItemCount { get; set; }
}
// Data-item shards, in creation order. A monitored item is placed on the first shard
// with free capacity; when all are full a new shard is created, and a shard is
// deleted once it empties. Mutated ONLY under _applyGate.
private readonly List<DataShard> _dataShards = new();
// Alarms & Conditions event items live on their own shard: TriggerConditionRefresh
// needs a single subscription id to target, and QueueSize:1000 event items would
// otherwise consume data-shard capacity. Created lazily, torn down on disconnect.
private Subscription? _eventShard;
// subscription handle → owning shard's Subscription, so a removal can group ONE
// ApplyChanges per touched shard.
private readonly ConcurrentDictionary<string, Subscription> _itemShard = new();
// The OPC Foundation Subscription object is not safe under concurrent structural
// mutation, and nothing else orders HandleSubscribe's background task, the
// tag-resolution probe and an alarm subscribe against each other. Every
// AddItem/RemoveItem/ApplyChanges/Create/Delete — and every shard-list mutation —
// serializes behind this ONE per-client gate. Batching makes contention rare; a
// single gate (rather than per-shard) also serializes shard creation and is the
// simplest thing that is correct.
private readonly AsyncSerialGate _applyGate = new();
// These maps are read from the OPC Foundation SDK's
// internal publish threads (the MonitoredItem.Notification handler reads
@@ -146,20 +181,68 @@ public class RealOpcUaClient : IOpcUaClient
// Store options for monitored item creation
_options = opts;
// Create a default subscription for all monitored items
_subscription = new Subscription(_session.DefaultSubscription)
// Create the first data shard up front so a session is immediately usable;
// further shards are added on demand as the item budget fills.
await _applyGate.RunAsync(async () =>
{
DisplayName = opts.SubscriptionDisplayName,
Priority = opts.SubscriptionPriority,
_dataShards.Clear();
_itemShard.Clear();
_eventShard = null;
await CreateDataShardAsync(cancellationToken);
}, cancellationToken);
}
/// <summary>Item budget per subscription, guarding against a non-positive configured value.</summary>
private int ItemsPerShard => _options.MaxMonitoredItemsPerSubscription > 0
? _options.MaxMonitoredItemsPerSubscription
: 5000;
/// <summary>
/// Builds a Subscription from the current connection options. Callers must hold
/// <see cref="_applyGate"/>.
/// </summary>
private Subscription NewSubscription(string displayName) =>
new(_session!.DefaultSubscription)
{
DisplayName = displayName,
Priority = _options.SubscriptionPriority,
PublishingEnabled = true,
PublishingInterval = opts.PublishingIntervalMs,
KeepAliveCount = (uint)opts.KeepAliveCount,
LifetimeCount = (uint)opts.LifetimeCount,
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
PublishingInterval = _options.PublishingIntervalMs,
KeepAliveCount = (uint)_options.KeepAliveCount,
LifetimeCount = (uint)_options.LifetimeCount,
MaxNotificationsPerPublish = (uint)_options.MaxNotificationsPerPublish
};
_session.AddSubscription(_subscription);
await _subscription.CreateAsync(cancellationToken);
/// <summary>
/// Adds and creates a new data shard. Callers must hold <see cref="_applyGate"/>.
/// </summary>
private async Task<DataShard> CreateDataShardAsync(CancellationToken cancellationToken)
{
var suffix = _dataShards.Count == 0 ? string.Empty : $"-{_dataShards.Count + 1}";
var subscription = NewSubscription(_options.SubscriptionDisplayName + suffix);
_session!.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken);
var shard = new DataShard(subscription);
_dataShards.Add(shard);
_logger.LogDebug("OPC UA data shard {Shard} created (budget {Budget} items)",
subscription.DisplayName, ItemsPerShard);
return shard;
}
/// <summary>
/// Returns the dedicated Alarms &amp; Conditions event shard, creating it on first
/// use. Callers must hold <see cref="_applyGate"/>.
/// </summary>
private async Task<Subscription> GetOrCreateEventShardAsync(CancellationToken cancellationToken)
{
if (_eventShard != null)
return _eventShard;
var subscription = NewSubscription(_options.SubscriptionDisplayName + "-events");
_session!.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken);
_eventShard = subscription;
return subscription;
}
/// <summary>
@@ -439,11 +522,19 @@ public class RealOpcUaClient : IOpcUaClient
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_subscription != null)
await _applyGate.RunAsync(async () =>
{
await _subscription.DeleteAsync(true);
_subscription = null;
}
foreach (var shard in _dataShards)
await shard.Subscription.DeleteAsync(true);
_dataShards.Clear();
_itemShard.Clear();
if (_eventShard != null)
{
await _eventShard.DeleteAsync(true);
_eventShard = null;
}
}, cancellationToken);
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
@@ -459,55 +550,230 @@ public class RealOpcUaClient : IOpcUaClient
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
throw new InvalidOperationException("Not connected.");
// Batch-of-one delegation: the single-node entry point is kept (heartbeat monitor,
// interactive paths) but shares the batch implementation so there is exactly one
// placement / ApplyChanges code path. A per-item failure throws here — preserving
// the caller's historical "create throws on failure" contract AND the exception
// TYPE, so DataConnectionActor.IsConnectionLevelFailure still classifies a
// resolution failure as a tag-resolution failure rather than a connection fault.
var (outcomes, errors) = await CreateSubscriptionsCoreAsync(
[nodeId], onValueChanged, cancellationToken);
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = _options.DiscardOldest,
Filter = BuildDataChangeFilter(_options.Deadband)
};
var outcome = outcomes[0];
if (outcome.Success)
return outcome.SubscriptionHandle!;
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value?.Value;
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var statusCode = notification.Value?.StatusCode.Code ?? 0;
if (_callbacks.TryGetValue(handle, out var cb))
{
cb(nodeId, value, timestamp, statusCode);
}
}
};
_subscription.AddItem(monitoredItem);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems[handle] = monitoredItem;
return handle;
if (errors[0] is { } captured)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(captured).Throw();
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown,
outcome.Error ?? $"Monitored item for '{nodeId}' could not be created.");
}
/// <inheritdoc />
public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<OpcUaSubscribeOutcome>> CreateSubscriptionsAsync(
IReadOnlyList<string> nodeIds,
Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item))
var (outcomes, _) = await CreateSubscriptionsCoreAsync(nodeIds, onValueChanged, cancellationToken);
return outcomes;
}
/// <summary>
/// Creates monitored items for every requested node with ONE ApplyChanges per touched
/// shard — the core of the batch subscribe seam. Node-id resolution runs outside the
/// apply lock (it is pure over the session's namespace table); placement, AddItem and
/// ApplyChanges run under it. Returns per-node outcomes plus the captured exception
/// (when any) per node, so the single-node entry point can rethrow the original.
/// </summary>
private async Task<(OpcUaSubscribeOutcome[] Outcomes, Exception?[] Errors)> CreateSubscriptionsCoreAsync(
IReadOnlyList<string> nodeIds,
Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken)
{
var session = _session;
if (session == null)
throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaSubscribeOutcome[nodeIds.Count];
var errors = new Exception?[nodeIds.Count];
// Resolve first: a bad node id is a per-tag fault and must not abort the batch.
var resolved = new List<(int Index, string NodeId, NodeId Resolved)>(nodeIds.Count);
for (var i = 0; i < nodeIds.Count; i++)
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems.TryRemove(subscriptionHandle, out _);
_callbacks.TryRemove(subscriptionHandle, out _);
try
{
resolved.Add((i, nodeIds[i], OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris)));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaSubscribeOutcome(nodeIds[i], false, null, ex.Message);
errors[i] = ex;
}
}
if (resolved.Count == 0)
return (outcomes, errors);
return await _applyGate.RunAsync(async () =>
{
var created = new List<(int Index, string NodeId, string Handle, MonitoredItem Item)>(resolved.Count);
var touched = new List<DataShard>();
foreach (var (index, nodeId, startNode) in resolved)
{
var shard = await PlaceOnDataShardAsync(cancellationToken);
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(shard.Subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = startNode,
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = _options.DiscardOldest,
Filter = BuildDataChangeFilter(_options.Deadband)
};
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (_, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value?.Value;
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var statusCode = notification.Value?.StatusCode.Code ?? 0;
if (_callbacks.TryGetValue(handle, out var cb))
{
cb(nodeId, value, timestamp, statusCode);
}
}
};
shard.Subscription.AddItem(monitoredItem);
shard.ItemCount++;
_itemShard[handle] = shard.Subscription;
created.Add((index, nodeId, handle, monitoredItem));
if (!touched.Contains(shard))
touched.Add(shard);
}
// ONE ApplyChanges per touched shard — the whole point of the batch seam.
foreach (var shard in touched)
await shard.Subscription.ApplyChangesAsync(cancellationToken);
foreach (var (index, nodeId, handle, item) in created)
{
var error = item.Status?.Error;
if (error != null && ServiceResult.IsBad(error))
{
// Per-item create failure (unknown node, access denied): release the
// half-created item so the shard budget stays honest, and report a
// failed row. The actor treats it as an unresolved tag and retries it
// on the backoff timer.
ReleaseFailedItem(handle, item);
outcomes[index] = new OpcUaSubscribeOutcome(nodeId, false, null, error.ToString());
}
else
{
_monitoredItems[handle] = item;
outcomes[index] = new OpcUaSubscribeOutcome(nodeId, true, handle, null);
}
}
return (outcomes, errors);
}, cancellationToken);
}
/// <summary>
/// Returns the shard this item belongs on per
/// <see cref="MonitoredItemShardPlanner"/> — first shard with free capacity, else a
/// freshly created one. Callers must hold <see cref="_applyGate"/>.
/// </summary>
private async Task<DataShard> PlaceOnDataShardAsync(CancellationToken cancellationToken)
{
var index = MonitoredItemShardPlanner.Plan(
_dataShards.Select(s => s.ItemCount).ToList(), ItemsPerShard, 1)[0];
return index < _dataShards.Count
? _dataShards[index]
: await CreateDataShardAsync(cancellationToken);
}
/// <summary>
/// Drops a monitored item that failed to create from the shard it was placed on.
/// Callers must hold <see cref="_applyGate"/>. No ApplyChanges is issued: the server
/// never created the item, so removing it locally keeps the budget honest.
/// </summary>
private void ReleaseFailedItem(string handle, MonitoredItem item)
{
_callbacks.TryRemove(handle, out _);
if (!_itemShard.TryRemove(handle, out var subscription))
return;
subscription.RemoveItem(item);
var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
if (shard is { ItemCount: > 0 })
shard.ItemCount--;
}
/// <inheritdoc />
public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
=> RemoveSubscriptionsAsync([subscriptionHandle], cancellationToken);
/// <inheritdoc />
public async Task RemoveSubscriptionsAsync(
IReadOnlyList<string> subscriptionHandles, CancellationToken cancellationToken = default)
{
if (subscriptionHandles.Count == 0)
return;
await _applyGate.RunAsync(async () =>
{
var touched = new List<DataShard>();
foreach (var handle in subscriptionHandles)
{
if (!_monitoredItems.TryGetValue(handle, out var item))
continue;
if (!_itemShard.TryGetValue(handle, out var subscription))
continue;
subscription.RemoveItem(item);
_monitoredItems.TryRemove(handle, out _);
_callbacks.TryRemove(handle, out _);
_itemShard.TryRemove(handle, out _);
var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
if (shard == null)
continue;
if (shard.ItemCount > 0)
shard.ItemCount--;
if (!touched.Contains(shard))
touched.Add(shard);
}
// ONE ApplyChanges per touched shard, then drop the shards that emptied.
foreach (var shard in touched)
{
await shard.Subscription.ApplyChangesAsync(cancellationToken);
if (shard.ItemCount != 0)
continue;
_dataShards.Remove(shard);
try
{
await shard.Subscription.DeleteAsync(true);
}
catch (Exception ex)
{
// Best-effort: an empty shard the server already dropped must not fail
// the unsubscribe path.
_logger.LogDebug(ex, "Deleting emptied OPC UA data shard {Shard} failed (ignored).",
shard.Subscription.DisplayName);
}
}
}, cancellationToken);
}
// ── Native alarm (Alarms & Conditions) subscription ──
@@ -523,7 +789,7 @@ public class RealOpcUaClient : IOpcUaClient
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
if (_session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
@@ -533,7 +799,14 @@ public class RealOpcUaClient : IOpcUaClient
var startNode = string.IsNullOrEmpty(sourceNodeId)
? ObjectIds.Server
: OpcUaNodeReference.Resolve(sourceNodeId, _session.NamespaceUris);
var item = new MonitoredItem(_subscription.DefaultItem)
// A&C event items live on their own shard (see _eventShard): ConditionRefresh
// needs one subscription id to target, and QueueSize:1000 event items must not
// occupy data-shard capacity.
var eventShard = await _applyGate.RunAsync(
() => GetOrCreateEventShardAsync(cancellationToken), cancellationToken);
var item = new MonitoredItem(eventShard.DefaultItem)
{
DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
StartNodeId = startNode,
@@ -554,22 +827,31 @@ public class RealOpcUaClient : IOpcUaClient
HandleAlarmEvent(handle, sourceNodeId, efl, onTransition);
};
_subscription.AddItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
await _applyGate.RunAsync(async () =>
{
eventShard.AddItem(item);
await eventShard.ApplyChangesAsync(cancellationToken);
}, cancellationToken);
_alarmItems[handle] = item;
// Replay currently-active conditions as a Snapshot…SnapshotComplete sequence.
await TriggerConditionRefreshAsync(handle, cancellationToken);
await TriggerConditionRefreshAsync(handle, eventShard, cancellationToken);
return handle;
}
/// <inheritdoc />
public async Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _alarmItems.TryRemove(subscriptionHandle, out var item))
if (_alarmItems.TryRemove(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
await _applyGate.RunAsync(async () =>
{
if (_eventShard != null)
{
_eventShard.RemoveItem(item);
await _eventShard.ApplyChangesAsync(cancellationToken);
}
}, cancellationToken);
}
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
_alarmLastState.TryRemove(subscriptionHandle, out _);
@@ -775,15 +1057,17 @@ public class RealOpcUaClient : IOpcUaClient
};
}
private async Task TriggerConditionRefreshAsync(string handle, CancellationToken cancellationToken)
private async Task TriggerConditionRefreshAsync(
string handle, Subscription eventShard, CancellationToken cancellationToken)
{
try
{
// ConditionRefresh replays active conditions; RefreshStart/End events
// bracket the replay so HandleAlarmEvent can mark them Snapshot.
// bracket the replay so HandleAlarmEvent can mark them Snapshot. It targets
// the EVENT shard's subscription id — the shard every A&C item is pinned to.
await _session!.CallAsync(
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh,
cancellationToken, _subscription!.Id);
cancellationToken, eventShard.Id);
}
catch (Exception ex)
{
@@ -906,42 +1190,143 @@ public class RealOpcUaClient : IOpcUaClient
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
/// <summary>
/// Nodes per OPC UA Read/Write service call. Stays under the
/// <c>MaxNodesPerRead</c> / <c>MaxNodesPerWrite</c> operation limits typical servers
/// advertise, so a 37,500-tag re-seed never builds one oversized request.
/// </summary>
private const int MaxNodesPerServiceCall = 1000;
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
// Batch-of-one delegation; a resolution failure still surfaces as the thrown
// exception the single-node callers expect.
var outcomes = await ReadValuesAsync([nodeId], cancellationToken);
var outcome = outcomes[0];
if (outcome.Error != null)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
return (outcome.Value, outcome.SourceTimestamp, outcome.StatusCode);
}
var readValue = new ReadValueId
/// <inheritdoc />
public async Task<IReadOnlyList<OpcUaReadOutcome>> ReadValuesAsync(
IReadOnlyList<string> nodeIds, CancellationToken cancellationToken = default)
{
var session = _session;
if (session == null) throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaReadOutcome[nodeIds.Count];
// Resolve first — an unresolvable node id is a per-node fault, not a batch abort.
var resolvable = new List<(int Index, string NodeId, ReadValueId Read)>(nodeIds.Count);
for (var i = 0; i < nodeIds.Count; i++)
{
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value
};
try
{
resolvable.Add((i, nodeIds[i], new ReadValueId
{
NodeId = OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris),
AttributeId = Attributes.Value
}));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown, ex.Message);
}
}
var response = await _session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn),
new ReadValueIdCollection { readValue }, cancellationToken);
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
var collection = new ReadValueIdCollection(chunk.Select(c => c.Read));
var response = await session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), collection, cancellationToken);
var result = response.Results[0];
return (result.Value, result.SourceTimestamp, result.StatusCode.Code);
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
if (response.Results is { Count: > 0 } results && i < results.Count)
{
var result = results[i];
outcomes[index] = new OpcUaReadOutcome(
nodeId, result.Value, result.SourceTimestamp, result.StatusCode.Code, null);
}
else
{
// Non-conformant server: fewer results than requested nodes.
outcomes[index] = new OpcUaReadOutcome(
nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError,
"OPC UA read returned no result for this node.");
}
}
}
return outcomes;
}
/// <inheritdoc />
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
// Batch-of-one delegation, mirroring ReadValueAsync.
var outcomes = await WriteValuesAsync([(nodeId, value)], cancellationToken);
var outcome = outcomes[0];
if (outcome.Error != null)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
return outcome.StatusCode;
}
var writeValue = new WriteValue
/// <inheritdoc />
public async Task<IReadOnlyList<OpcUaWriteOutcome>> WriteValuesAsync(
IReadOnlyList<(string NodeId, object? Value)> values, CancellationToken cancellationToken = default)
{
var session = _session;
if (session == null) throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaWriteOutcome[values.Count];
var resolvable = new List<(int Index, string NodeId, WriteValue Write)>(values.Count);
for (var i = 0; i < values.Count; i++)
{
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
try
{
resolvable.Add((i, values[i].NodeId, new WriteValue
{
NodeId = OpcUaNodeReference.Resolve(values[i].NodeId, session.NamespaceUris),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(values[i].Value))
}));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown, ex.Message);
}
}
var response = await _session.WriteAsync(
null, new WriteValueCollection { writeValue }, cancellationToken);
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
var collection = new WriteValueCollection(chunk.Select(c => c.Write));
var response = await session.WriteAsync(null, collection, cancellationToken);
return response.Results[0].Code;
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
if (response.Results is { Count: > 0 } results && i < results.Count)
{
outcomes[index] = new OpcUaWriteOutcome(nodeId, results[i].Code, null);
}
else
{
outcomes[index] = new OpcUaWriteOutcome(
nodeId, StatusCodes.BadUnexpectedError,
"OPC UA write returned no result for this node.");
}
}
}
return outcomes;
}
/// <summary>