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