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
@@ -21,11 +21,13 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <see cref="Disconnected"/>, the actor disposes this adapter, creates a fresh one,
/// reconnects and re-subscribes all tags.
/// </summary>
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
public class MxGatewayDataConnection
: IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IBatchSubscribableConnection
{
private readonly IMxGatewayClientFactory _clientFactory;
private readonly ILogger<MxGatewayDataConnection> _logger;
private readonly ISecretResolver? _secretResolver;
private readonly int _supervisoryAdviseParallelism;
private IMxGatewayClient? _client;
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private CancellationTokenSource? _eventLoopCts;
@@ -39,6 +41,13 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
private int _alarmSubCount;
private readonly object _alarmLock = new();
// Source references currently subscribed (alarm subscription id → source reference),
// and the prefix the live stream was opened with. StreamAlarms carries ONE prefix, so
// the pushable union is the longest common prefix of the active sources: the stream is
// restarted only when a NEW source falls outside it. Guarded by _alarmLock.
private readonly Dictionary<string, string> _alarmSubSources = new(StringComparer.Ordinal);
private string _alarmStreamPrefix = string.Empty;
// subscriptionId → (tagPath, callback) so the event loop can route updates by tag,
// plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
// loop reads from a background thread while Subscribe/Unsubscribe mutate.
@@ -58,14 +67,20 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
/// connect time. When null, a literal ApiKey still works, but a <c>secret:</c> reference
/// fails closed (a connection is never established with an unresolved key).
/// </param>
/// <param name="supervisoryAdviseParallelism">
/// Maximum in-flight supervisory advise commands on the bulk-subscribe path when the
/// endpoint has no write-user context (<c>DataConnectionOptions.MxSupervisoryAdviseParallelism</c>).
/// </param>
public MxGatewayDataConnection(
IMxGatewayClientFactory clientFactory,
ILogger<MxGatewayDataConnection> logger,
ISecretResolver? secretResolver = null)
ISecretResolver? secretResolver = null,
int supervisoryAdviseParallelism = 16)
{
_clientFactory = clientFactory;
_logger = logger;
_secretResolver = secretResolver;
_supervisoryAdviseParallelism = Math.Max(1, supervisoryAdviseParallelism);
}
/// <inheritdoc />
@@ -129,7 +144,8 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
cfg.UseTls,
string.IsNullOrWhiteSpace(cfg.CaFile) ? null : cfg.CaFile,
string.IsNullOrWhiteSpace(cfg.ServerName) ? null : cfg.ServerName,
cfg.ReadTimeoutMs), cancellationToken);
cfg.ReadTimeoutMs,
_supervisoryAdviseParallelism), cancellationToken);
_status = ConnectionHealth.Connected;
@@ -192,6 +208,8 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
_alarmCts?.Dispose();
_alarmCts = null;
_alarmSubCount = 0;
_alarmSubSources.Clear();
_alarmStreamPrefix = string.Empty;
}
if (_client is not null)
await _client.DisconnectAsync(cancellationToken);
@@ -215,28 +233,79 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
{
// ONE gateway round trip per batch (SubscribeBulk = AddItem + Advise per tag in a
// single worker pass) instead of the historical 2 RPCs per tag.
var outcomes = await _client!.SubscribeBulkAsync(tagPaths, cancellationToken);
var results = new List<TagSubscribeResult>(outcomes.Count);
foreach (var outcome in outcomes)
{
if (outcome is { Success: true, SubscriptionId: not null })
{
_subs[outcome.SubscriptionId] = (outcome.TagPath, callback);
_tagToSub[outcome.TagPath] = outcome.SubscriptionId;
}
results.Add(new TagSubscribeResult(
outcome.TagPath, outcome.Success, outcome.SubscriptionId, outcome.Error));
}
return results;
}
/// <inheritdoc />
public async Task UnsubscribeBatchAsync(
IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
{
foreach (var id in subscriptionIds)
{
if (_subs.TryRemove(id, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
}
await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
}
/// <inheritdoc />
public Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter,
AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
{
var subscriptionId = Guid.NewGuid().ToString();
lock (_alarmLock)
{
_alarmSubCount++;
if (_alarmCts == null)
_alarmSubSources[subscriptionId] = sourceReference;
// Push the UNION of the subscribed source references to the gateway as the
// stream's single filter prefix. It is a bandwidth optimisation only — the
// MxGateway has no server-side CONDITION filter either, so conditionFilter is
// still not forwarded: the DataConnectionActor's per-source + per-condition-type
// gate stays authoritative, uniform with the OPC UA WhereClause stance.
if (_alarmCts != null && AlarmFilterPrefix.Covers(_alarmStreamPrefix, sourceReference))
return Task.FromResult(subscriptionId);
// A new source outside the live prefix (or the first subscriber): (re)open the
// stream on the recomputed union. The source replays a fresh snapshot, which
// NativeAlarmActor already handles.
if (_alarmCts != null)
{
_alarmCts = new CancellationTokenSource();
var token = _alarmCts.Token;
var client = _client!;
// Gateway-wide feed (null prefix). The MxGateway has no server-side
// condition filter, so conditionFilter is intentionally NOT forwarded
// here: the DataConnectionActor applies it as the authoritative
// client-side gate per source reference AND per condition type
// (AlarmConditionFilter), uniform with the OPC UA path.
_ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token);
_alarmCts.Cancel();
_alarmCts.Dispose();
}
_alarmStreamPrefix = AlarmFilterPrefix.LongestCommonPrefix(_alarmSubSources.Values);
_alarmCts = new CancellationTokenSource();
var token = _alarmCts.Token;
var client = _client!;
var prefix = _alarmStreamPrefix.Length == 0 ? null : _alarmStreamPrefix;
_ = Task.Run(() => client.RunAlarmStreamAsync(prefix, t => callback(t), token), token);
}
return Task.FromResult(Guid.NewGuid().ToString());
return Task.FromResult(subscriptionId);
}
/// <inheritdoc />
@@ -244,13 +313,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
{
lock (_alarmLock)
{
_alarmSubSources.Remove(subscriptionId);
if (_alarmSubCount > 0)
_alarmSubCount--;
// Deliberately no stream restart here: dropping a source can only leave the
// prefix too BROAD, which costs bandwidth, never correctness.
if (_alarmSubCount == 0)
{
_alarmCts?.Cancel();
_alarmCts?.Dispose();
_alarmCts = null;
_alarmStreamPrefix = string.Empty;
}
}
return Task.CompletedTask;
@@ -354,6 +427,8 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
_alarmCts?.Dispose();
_alarmCts = null;
_alarmSubCount = 0;
_alarmSubSources.Clear();
_alarmStreamPrefix = string.Empty;
}
if (_client is not null)
await _client.DisposeAsync();