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);
}
}