fix(data-connection-layer): resolve DataConnectionLayer-008,013 — O(1) unsubscribe via reverse index, atomic disconnect guard

This commit is contained in:
Joseph Doherty
2026-05-16 22:14:23 -04:00
parent 7d1cc5cbb4
commit ff4a4bdeb7
6 changed files with 196 additions and 24 deletions

View File

@@ -836,4 +836,63 @@ public class DataConnectionActorTests : TestKit
Assert.Equal(5, report.TotalSubscribedTags); // all 5 tags tracked
Assert.Equal(3, report.ResolvedTags); // only the 3 good ones resolved
}
// ── DataConnectionLayer-008: HandleUnsubscribe shared-tag reference counting ──
[Fact]
public async Task DCL008_Unsubscribe_OnlyReleasesTagWhenLastSubscriberLeaves()
{
// Regression test for DataConnectionLayer-008. HandleUnsubscribe must release a
// tag at the adapter only when no other instance still subscribes to it. The
// O(n) per-tag scan over every instance was replaced with an O(1) reference
// count; this guards that the reference count tracks shared subscriptions
// correctly — a shared tag is kept while any subscriber remains and the
// resolved-tag counter and adapter UnsubscribeAsync stay consistent.
var unsubscribed = new System.Collections.Concurrent.ConcurrentBag<string>();
_mockAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_mockAdapter.Status.Returns(ConnectionHealth.Connected);
_mockAdapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns(ci => Task.FromResult("sub-" + (string)ci[0]));
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
_mockAdapter.UnsubscribeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => { unsubscribed.Add((string)ci[0]); return Task.CompletedTask; });
var actor = CreateConnectionActor("dcl008-shared");
await Task.Delay(300);
// Two instances both subscribe to the shared tag; instA also has an exclusive tag.
actor.Tell(new SubscribeTagsRequest("c1", "instA", "dcl008-shared",
["shared/tag", "exclusive/a"], DateTimeOffset.UtcNow));
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
actor.Tell(new SubscribeTagsRequest("c2", "instB", "dcl008-shared",
["shared/tag"], DateTimeOffset.UtcNow));
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
// Unsubscribe instA — shared/tag must stay (instB still subscribes); only
// exclusive/a is released at the adapter.
actor.Tell(new UnsubscribeTagsRequest("c3", "instA", "dcl008-shared", DateTimeOffset.UtcNow));
await Task.Delay(300);
Assert.Contains("sub-exclusive/a", unsubscribed);
Assert.DoesNotContain("sub-shared/tag", unsubscribed);
// Health: 1 tag still subscribed and resolved (shared/tag held by instB).
actor.Tell(new DataConnectionActor.GetHealthReport());
var report1 = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(3));
Assert.Equal(1, report1.TotalSubscribedTags);
Assert.Equal(1, report1.ResolvedTags);
// Unsubscribe instB — now shared/tag has no subscribers and is released.
actor.Tell(new UnsubscribeTagsRequest("c4", "instB", "dcl008-shared", DateTimeOffset.UtcNow));
await Task.Delay(300);
Assert.Contains("sub-shared/tag", unsubscribed);
actor.Tell(new DataConnectionActor.GetHealthReport());
var report2 = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(3));
Assert.Equal(0, report2.TotalSubscribedTags);
Assert.Equal(0, report2.ResolvedTags);
}
}

View File

@@ -116,6 +116,45 @@ public class OpcUaDataConnectionTests
Assert.Equal(ConnectionHealth.Disconnected, _adapter.Status);
}
[Fact]
public async Task DCL013_ConcurrentConnectionLost_RaisesDisconnectedExactlyOnce()
{
// Regression test for DataConnectionLayer-013. RaiseDisconnected used a
// non-atomic check-then-set on a volatile bool: two threads racing through it
// (e.g. the keep-alive thread and a ReadAsync failure path, both routed via
// OnClientConnectionLost) could both observe _disconnectFired == false and both
// invoke Disconnected. The guard is now an atomic Interlocked.Exchange, so a
// burst of concurrent connection-lost callbacks fires the event exactly once.
// Repeat the burst: reconnecting between rounds re-arms the guard, so each
// round must independently fire Disconnected exactly once. Repetition makes
// the (timing-dependent) non-atomic race overwhelmingly likely to be caught.
const int rounds = 25;
const int threads = 32;
for (var round = 0; round < rounds; round++)
{
_mockClient.IsConnected.Returns(true);
await _adapter.ConnectAsync(new Dictionary<string, string>());
var fired = 0;
void Handler() => Interlocked.Increment(ref fired);
_adapter.Disconnected += Handler;
// Fan out: many threads raise the client's ConnectionLost event together.
using (var ready = new Barrier(threads))
{
var tasks = Enumerable.Range(0, threads).Select(_ => Task.Run(() =>
{
ready.SignalAndWait();
_mockClient.ConnectionLost += Raise.Event<Action>();
})).ToArray();
await Task.WhenAll(tasks);
}
_adapter.Disconnected -= Handler;
Assert.Equal(1, fired);
}
}
[Fact]
public async Task Subscribe_DelegatesAndReturnsId()
{