perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions
This commit is contained in:
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user