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