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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user