fix(data-connection-layer): resolve DataConnectionLayer-008,013 — O(1) unsubscribe via reverse index, atomic disconnect guard
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user