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
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable
public class OpcUaDataConnection
: IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable,
IBatchSubscribableConnection
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
@@ -93,7 +95,8 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
? new OpcUaUserIdentityOptions(
ui.TokenType.ToString(), ui.Username, ui.Password,
ui.CertificatePath, ui.CertificatePassword)
: null);
: null,
MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription);
_status = ConnectionHealth.Connecting;
@@ -193,6 +196,36 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
{
EnsureConnected();
// ONE shared callback for the whole batch — the client hands back the node id
// (== tag path for OPC UA), so no per-tag closure is needed.
var outcomes = await _client!.CreateSubscriptionsAsync(
tagPaths,
(nodeId, value, timestamp, statusCode) =>
{
var quality = MapStatusCode(statusCode);
callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
},
cancellationToken);
return outcomes
.Select(o => new TagSubscribeResult(o.NodeId, o.Success, o.SubscriptionHandle, o.Error))
.ToList();
}
/// <inheritdoc />
public async Task UnsubscribeBatchAsync(
IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
{
if (_client != null)
await _client.RemoveSubscriptionsAsync(subscriptionIds, cancellationToken);
}
/// <inheritdoc />
public async Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter,
@@ -249,27 +282,59 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
{
// A single failing tag must not abort the whole batch.
// ReadAsync re-throws non-cancellation exceptions; catch them per tag and record
// a failed ReadResult so the caller receives a complete result map for every
// requested tag (the ReadResult shape already carries per-tag Success/error).
var results = new Dictionary<string, ReadResult>();
foreach (var tagPath in tagPaths)
EnsureConnected();
// TRUE bulk: one OPC UA Read service call (chunked inside the client against the
// server's operation limits), not a loop over ReadAsync. A single failing tag
// still comes back as a failed ReadResult row rather than aborting the batch;
// OperationCanceledException still aborts the whole batch.
var requested = tagPaths as IReadOnlyList<string> ?? tagPaths.ToList();
var results = new Dictionary<string, ReadResult>(requested.Count);
if (requested.Count == 0)
return results;
try
{
try
var outcomes = await _client!.ReadValuesAsync(requested, cancellationToken);
foreach (var outcome in outcomes)
{
results[tagPath] = await ReadAsync(tagPath, cancellationToken);
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
results[tagPath] = new ReadResult(false, null, ex.Message);
if (outcome.Error != null)
{
results[outcome.NodeId] = new ReadResult(false, null, outcome.Error);
continue;
}
var quality = MapStatusCode(outcome.StatusCode);
results[outcome.NodeId] = quality == QualityCode.Bad
? new ReadResult(false, null, $"OPC UA read returned bad status: 0x{outcome.StatusCode:X8}")
: new ReadResult(true,
new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)),
null);
}
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
// A batch-level fault (session dropped mid-call) mirrors ReadAsync: signal the
// disconnect, then report every requested tag as failed so the caller still
// receives a complete map instead of an exception.
_logger.LogWarning(ex, "OPC UA batch read failed — connection may be lost");
RaiseDisconnected();
foreach (var tagPath in requested)
results[tagPath] = new ReadResult(false, null, ex.Message);
}
// Defensive: a non-conformant client/server that omits a requested tag.
foreach (var tagPath in requested)
{
if (!results.ContainsKey(tagPath))
results[tagPath] = new ReadResult(false, null, "Tag missing from OPC UA read result.");
}
return results;
}
@@ -288,29 +353,46 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
{
// A mid-batch fault must not abort the whole batch.
// WriteAsync calls EnsureConnected(), which throws InvalidOperationException when
// the connection drops partway through; catch per-tag exceptions and record a
// failed WriteResult so the caller (including WriteBatchAndWaitAsync) receives a
// complete result map. OperationCanceledException is still propagated so a
// cancelled batch aborts as a whole — mirrors the ReadBatchAsync fix.
var results = new Dictionary<string, WriteResult>();
foreach (var (tagPath, value) in values)
EnsureConnected();
// TRUE bulk: one OPC UA Write service call (chunked inside the client). A mid-batch
// fault must not abort the batch — every requested tag gets a WriteResult row —
// while OperationCanceledException still aborts as a whole.
var requested = values.ToList();
var results = new Dictionary<string, WriteResult>(requested.Count);
if (requested.Count == 0)
return results;
try
{
try
var outcomes = await _client!.WriteValuesAsync(
requested.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken);
foreach (var outcome in outcomes)
{
results[tagPath] = await WriteAsync(tagPath, value, cancellationToken);
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
results[tagPath] = new WriteResult(false, ex.Message);
results[outcome.NodeId] = outcome.Error != null
? new WriteResult(false, outcome.Error)
: outcome.StatusCode != 0
? new WriteResult(false, $"OPC UA write failed with status: 0x{outcome.StatusCode:X8}")
: new WriteResult(true, null);
}
}
catch (OperationCanceledException)
{
// Cancellation aborts the whole batch — propagate it.
throw;
}
catch (Exception ex)
{
foreach (var (tagPath, _) in requested)
results[tagPath] = new WriteResult(false, ex.Message);
}
foreach (var (tagPath, _) in requested)
{
if (!results.ContainsKey(tagPath))
results[tagPath] = new WriteResult(false, "Tag missing from OPC UA write result.");
}
return results;
}