fix(dcl): advance the session generation on same-adapter reconnect and session-scope MxGateway subscription ids (review 2026-10-21 02/C1, 02/L1)
This commit is contained in:
@@ -52,9 +52,16 @@ public class MxGatewayDataConnection
|
||||
// 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.
|
||||
// Keys are SESSION-SCOPED ids ("{session}:{handle}") — see ScopeId.
|
||||
private readonly ConcurrentDictionary<string, (string TagPath, SubscriptionCallback Callback)> _subs = new();
|
||||
private readonly ConcurrentDictionary<string, string> _tagToSub = new();
|
||||
|
||||
// Incremented by every ConnectAsync (each opens a NEW gateway session). Subscription ids
|
||||
// handed out carry the session they were minted in, because gateway item handles are plain
|
||||
// ints that restart — i.e. collide — per session (review 2026-10-21 02/C1): a dead
|
||||
// session's id must never parse to a live handle and unadvise someone else's item.
|
||||
private int _session;
|
||||
|
||||
// DataConnectionLayer mirror of OpcUaDataConnection's once-only guard: an int toggled
|
||||
// with Interlocked.Exchange so only the first caller raises Disconnected.
|
||||
// 0 = not fired, 1 = fired. Reset on (re)connect.
|
||||
@@ -158,6 +165,12 @@ public class MxGatewayDataConnection
|
||||
_alarmStreamPrefix = string.Empty;
|
||||
}
|
||||
|
||||
// New session: the previous session's items died with it. Drop their routing entries
|
||||
// (review 2026-10-21 02/L1 — they were never removed and grew per flap) and advance the
|
||||
// session so their ids are recognised as dead from now on.
|
||||
Interlocked.Increment(ref _session);
|
||||
ClearSubscriptionMaps();
|
||||
|
||||
Interlocked.Exchange(ref _disconnectFired, 0); // reset guard on (re)connect, like OPC UA
|
||||
_client = _clientFactory.Create();
|
||||
|
||||
@@ -240,10 +253,46 @@ public class MxGatewayDataConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops every subscription routing entry (the session they belong to is gone).</summary>
|
||||
private void ClearSubscriptionMaps()
|
||||
{
|
||||
_subs.Clear();
|
||||
_tagToSub.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Formats a gateway item handle as a subscription id scoped to <paramref name="session"/>.</summary>
|
||||
private static string ScopeId(int session, string handle) => $"{session}:{handle}";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the raw gateway handle from a session-scoped id, or returns <c>null</c> when the
|
||||
/// id was minted by a session other than the live one (or is not one of ours) — such a handle
|
||||
/// must never be sent to the gateway.
|
||||
/// </summary>
|
||||
private string? CurrentSessionHandle(string subscriptionId)
|
||||
{
|
||||
var sep = subscriptionId.IndexOf(':');
|
||||
if (sep <= 0
|
||||
|| !int.TryParse(subscriptionId.AsSpan(0, sep), System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var session)
|
||||
|| session != Volatile.Read(ref _session))
|
||||
return null;
|
||||
return subscriptionId[(sep + 1)..];
|
||||
}
|
||||
|
||||
/// <summary>Removes an id's routing entries; returns true if it was a known subscription.</summary>
|
||||
private bool ForgetSubscription(string subscriptionId)
|
||||
{
|
||||
if (!_subs.TryRemove(subscriptionId, out var s))
|
||||
return false;
|
||||
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, subscriptionId));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_eventLoopCts?.Cancel();
|
||||
ClearSubscriptionMaps();
|
||||
lock (_alarmLock)
|
||||
{
|
||||
_alarmCts?.Cancel();
|
||||
@@ -261,18 +310,26 @@ public class MxGatewayDataConnection
|
||||
/// <inheritdoc />
|
||||
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var subId = await _client!.SubscribeAsync(tagPath, cancellationToken);
|
||||
_subs[subId] = (tagPath, callback);
|
||||
_tagToSub[tagPath] = subId;
|
||||
// Capture the session with the client it belongs to: a reconnect landing while this
|
||||
// call is in flight must not route the dead session's handle into the new maps.
|
||||
var session = Volatile.Read(ref _session);
|
||||
var handle = await _client!.SubscribeAsync(tagPath, cancellationToken);
|
||||
var subId = ScopeId(session, handle);
|
||||
if (session == Volatile.Read(ref _session))
|
||||
{
|
||||
_subs[subId] = (tagPath, callback);
|
||||
_tagToSub[tagPath] = subId;
|
||||
}
|
||||
return subId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_subs.TryRemove(subscriptionId, out var s))
|
||||
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, subscriptionId));
|
||||
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
|
||||
ForgetSubscription(subscriptionId);
|
||||
// A dead session's handle is never sent: the gateway may have reused the number.
|
||||
if (CurrentSessionHandle(subscriptionId) is { } handle)
|
||||
await _client!.UnsubscribeAsync(handle, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -281,18 +338,25 @@ public class MxGatewayDataConnection
|
||||
{
|
||||
// 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 session = Volatile.Read(ref _session);
|
||||
var outcomes = await _client!.SubscribeBulkAsync(tagPaths, cancellationToken);
|
||||
var current = session == Volatile.Read(ref _session);
|
||||
var results = new List<TagSubscribeResult>(outcomes.Count);
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
string? subId = null;
|
||||
if (outcome is { Success: true, SubscriptionId: not null })
|
||||
{
|
||||
_subs[outcome.SubscriptionId] = (outcome.TagPath, callback);
|
||||
_tagToSub[outcome.TagPath] = outcome.SubscriptionId;
|
||||
subId = ScopeId(session, outcome.SubscriptionId);
|
||||
if (current)
|
||||
{
|
||||
_subs[subId] = (outcome.TagPath, callback);
|
||||
_tagToSub[outcome.TagPath] = subId;
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(new TagSubscribeResult(
|
||||
outcome.TagPath, outcome.Success, outcome.SubscriptionId, outcome.Error));
|
||||
outcome.TagPath, outcome.Success, subId, outcome.Error));
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -302,13 +366,17 @@ public class MxGatewayDataConnection
|
||||
public async Task UnsubscribeBatchAsync(
|
||||
IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var handles = new List<string>(subscriptionIds.Count);
|
||||
foreach (var id in subscriptionIds)
|
||||
{
|
||||
if (_subs.TryRemove(id, out var s))
|
||||
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, id));
|
||||
ForgetSubscription(id);
|
||||
// A dead session's handle is never sent: the gateway may have reused the number.
|
||||
if (CurrentSessionHandle(id) is { } handle)
|
||||
handles.Add(handle);
|
||||
}
|
||||
|
||||
await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
|
||||
if (handles.Count > 0)
|
||||
await _client!.UnsubscribeBulkAsync(handles, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -371,7 +439,11 @@ public class MxGatewayDataConnection
|
||||
{
|
||||
lock (_alarmLock)
|
||||
{
|
||||
_alarmSubSources.Remove(subscriptionId);
|
||||
// An id this adapter does not know (a dead session's — ConnectAsync resets the
|
||||
// registry) must not decrement the live count: reaching zero tears down the shared
|
||||
// stream every live subscriber depends on.
|
||||
if (!_alarmSubSources.Remove(subscriptionId))
|
||||
return Task.CompletedTask;
|
||||
if (_alarmSubCount > 0)
|
||||
_alarmSubCount--;
|
||||
// Deliberately no stream restart here: dropping a source can only leave the
|
||||
@@ -444,6 +516,7 @@ public class MxGatewayDataConnection
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_eventLoopCts?.Cancel();
|
||||
ClearSubscriptionMaps();
|
||||
// The DataConnectionActor disposes adapters
|
||||
// fire-and-forget on failover/stop without necessarily calling
|
||||
// DisconnectAsync first, so tear down the alarm stream here too — otherwise
|
||||
|
||||
Reference in New Issue
Block a user