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:
@@ -24,6 +24,13 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
public Func<IReadOnlyList<string>, IReadOnlyList<MxReadOutcome>>? ReadHandler;
|
||||
public Func<IReadOnlyList<(string TagPath, object? Value)>, IReadOnlyList<MxWriteOutcome>>? WriteHandler;
|
||||
public Func<string?, (IReadOnlyList<MxBrowseChild>, bool)>? BrowseHandler;
|
||||
/// <summary>
|
||||
/// When true, every <see cref="ConnectAsync"/> restarts handle numbering at 1 — the
|
||||
/// gateway's per-session item handles, which collide across sessions.
|
||||
/// </summary>
|
||||
public bool RestartHandlesOnConnect;
|
||||
/// <summary>The transition callback of the most recently opened alarm stream.</summary>
|
||||
public Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition>? AlarmCallback;
|
||||
private int _nextHandle;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -33,6 +40,8 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default)
|
||||
{
|
||||
ConnectedWith = o;
|
||||
if (RestartHandlesOnConnect)
|
||||
_nextHandle = 0;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -115,6 +124,7 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
// recorded so the union-filter (longest-common-prefix) behaviour is testable.
|
||||
lock (AlarmStreamPrefixes)
|
||||
AlarmStreamPrefixes.Add(alarmFilterPrefix);
|
||||
AlarmCallback = onTransition;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -122,7 +122,8 @@ public class MxGatewayDataConnectionTests
|
||||
fake.OnUpdate!(new MxValueUpdate("T", 1, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.Equal(0, hits);
|
||||
Assert.Contains(subId, fake.Unsubscribed);
|
||||
// Ids are session-scoped ("{session}:{handle}"); the gateway receives the raw handle.
|
||||
Assert.Equal(subId[(subId.IndexOf(':') + 1)..], Assert.Single(fake.Unsubscribed));
|
||||
}
|
||||
|
||||
// ── read / write + error classification ──
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// The adapter half of review 2026-10-21 02/C1 + 02/L1. The adapter instance is reused across
|
||||
/// reconnects, but every reconnect opens a NEW gateway session whose item handles are plain ints
|
||||
/// that restart (collide) per session. A subscription id handed out by a dead session must
|
||||
/// therefore never reach the gateway again — it would unadvise whatever the live session gave that
|
||||
/// number — and the routing maps must not keep dead-session entries alive.
|
||||
/// </summary>
|
||||
[Collection("DataConnectionManagerActor")]
|
||||
public class MxGatewaySessionScopedHandleTests
|
||||
{
|
||||
private static Dictionary<string, string> Details() => new()
|
||||
{
|
||||
["Endpoint"] = "http://gw:5000",
|
||||
["ApiKey"] = "key",
|
||||
["ReadTimeoutMs"] = "2000",
|
||||
};
|
||||
|
||||
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
|
||||
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// A batch release of a dead-session id never reaches the gateway, even when the new session
|
||||
/// has handed the same handle number to a live tag — and that live tag keeps routing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeadSessionId_IsNeverReleasedAtTheGateway_EvenWhenTheHandleWasReused()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient { RestartHandlesOnConnect = true };
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
var dead = (await adapter.SubscribeBatchAsync(["Old.Tag"], (_, _) => { }))[0].SubscriptionId!;
|
||||
|
||||
await adapter.ConnectAsync(Details()); // reconnect: new session, handles restart at 1
|
||||
var liveHits = 0;
|
||||
var live = (await adapter.SubscribeBatchAsync(["Live.Tag"], (_, _) => liveHits++))[0].SubscriptionId!;
|
||||
|
||||
await adapter.UnsubscribeBatchAsync([dead]);
|
||||
await adapter.UnsubscribeAsync(dead);
|
||||
|
||||
Assert.Empty(fake.Unsubscribed);
|
||||
Assert.NotEqual(dead, live);
|
||||
|
||||
await WaitUntil(() => fake.OnUpdate is not null);
|
||||
fake.OnUpdate!(new MxValueUpdate("Live.Tag", 2, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
Assert.True(liveHits >= 1);
|
||||
|
||||
// A current-session id still releases normally.
|
||||
await adapter.UnsubscribeBatchAsync([live]);
|
||||
Assert.Single(fake.Unsubscribed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reconnect drops the previous session's routing entries (L1): an update for a tag the
|
||||
/// new session has not (re-)subscribed is not routed to the dead session's callback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_ClearsTheDeadSessionsRoutingEntries()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
var hits = 0;
|
||||
await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++);
|
||||
|
||||
await adapter.ConnectAsync(Details());
|
||||
await WaitUntil(() => fake.OnUpdate is not null);
|
||||
fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.Equal(0, hits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A disconnect drops the routing entries too, so a later update is not delivered to a
|
||||
/// subscription that no longer exists at the gateway.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disconnect_ClearsRoutingEntries()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
var hits = 0;
|
||||
await adapter.SubscribeBatchAsync(["A.Tag"], (_, _) => hits++);
|
||||
await WaitUntil(() => fake.OnUpdate is not null);
|
||||
|
||||
await adapter.DisconnectAsync();
|
||||
fake.OnUpdate!(new MxValueUpdate("A.Tag", 1, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
Assert.Equal(0, hits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releasing an alarm subscription id the adapter does not know (a dead session's) must not
|
||||
/// decrement the live feed's reference count — at zero the shared stream is torn down while
|
||||
/// live subscribers still depend on it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnknownAlarmSubscriptionId_DoesNotTearDownTheLiveStream()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var delivered = 0;
|
||||
await adapter.SubscribeAlarmsAsync("Tank01", null, t =>
|
||||
{
|
||||
if (t.Kind == AlarmTransitionKind.SnapshotComplete)
|
||||
Interlocked.Increment(ref delivered);
|
||||
});
|
||||
await WaitUntil(() => fake.AlarmCallback is not null);
|
||||
|
||||
await adapter.UnsubscribeAlarmsAsync(Guid.NewGuid().ToString());
|
||||
|
||||
fake.AlarmCallback!(MxGatewayAlarmMapper.SnapshotComplete());
|
||||
Assert.Equal(1, Volatile.Read(ref delivered));
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 30_000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
throw new TimeoutException("Condition not met in time.");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user