perf(dcl): tag->instances reverse index replaces per-update all-instances scan (P3)

This commit is contained in:
Joseph Doherty
2026-07-09 00:48:22 -04:00
parent f294b46263
commit 1673268aee
2 changed files with 126 additions and 8 deletions
@@ -534,6 +534,75 @@ public class DataConnectionActorTests : TestKit
c => c.GetMethodInfo().Name == "SubscribeAsync");
}
// ── PLAN-03 Task 17 (P3): tag→instances reverse index for the value fan-out hot path ──
private IActorRef CreateInstantSubscribeActor(string name)
{
_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(callInfo => Task.FromResult("sub-" + callInfo.ArgAt<string>(0)));
// Failed reads → no seed values are delivered, so the probes see only the ack plus
// whatever TagValueReceived we explicitly deliver.
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
return CreateConnectionActor(name);
}
[Fact]
public async Task Task17_TagFanOut_AfterOneInstanceUnsubscribes_OnlyRemainingSubscriberReceives()
{
// Pins the fan-out contract that the reverse index must preserve: after one of two
// instances sharing a tag unsubscribes, a subsequent value for that tag reaches only
// the remaining subscriber. Passes with the O(N) scan AND the index — the index's
// bookkeeping on unsubscribe is exactly what this guards.
var actor = CreateInstantSubscribeActor("task17-unsub");
await Task.Delay(300); // reach Connected
var probeA = CreateTestProbe();
var probeB = CreateTestProbe();
actor.Tell(new SubscribeTagsRequest("cA", "inst-A", "task17-unsub", ["shared-tag"], DateTimeOffset.UtcNow), probeA.Ref);
actor.Tell(new SubscribeTagsRequest("cB", "inst-B", "task17-unsub", ["shared-tag"], DateTimeOffset.UtcNow), probeB.Ref);
probeA.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
probeB.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
// inst-A leaves; the shared tag now has exactly one remaining subscriber.
actor.Tell(new UnsubscribeTagsRequest("cA2", "inst-A", "task17-unsub", DateTimeOffset.UtcNow), probeA.Ref);
await Task.Delay(200); // let the unsubscribe be processed before delivering the value
actor.Tell(new DataConnectionActor.TagValueReceived(
"shared-tag", new TagValue(42.0, QualityCode.Good, DateTimeOffset.UtcNow), 0));
var update = probeB.ExpectMsg<TagValueUpdate>(TimeSpan.FromSeconds(5));
Assert.Equal("shared-tag", update.TagPath);
Assert.Equal(42.0, update.Value);
probeA.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task Task17_TagFanOut_TwoInstancesDisjointTags_EachReceivesOnlyItsOwn()
{
// Pins that the index keys values to the right instances: two instances on disjoint
// tags each receive only their own tag's value, never the other's.
var actor = CreateInstantSubscribeActor("task17-disjoint");
await Task.Delay(300);
var probeA = CreateTestProbe();
var probeB = CreateTestProbe();
actor.Tell(new SubscribeTagsRequest("cA", "inst-A", "task17-disjoint", ["tag-A"], DateTimeOffset.UtcNow), probeA.Ref);
actor.Tell(new SubscribeTagsRequest("cB", "inst-B", "task17-disjoint", ["tag-B"], DateTimeOffset.UtcNow), probeB.Ref);
probeA.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
probeB.ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
actor.Tell(new DataConnectionActor.TagValueReceived(
"tag-A", new TagValue(1.0, QualityCode.Good, DateTimeOffset.UtcNow), 0));
var toA = probeA.ExpectMsg<TagValueUpdate>(TimeSpan.FromSeconds(5));
Assert.Equal("tag-A", toA.TagPath);
probeB.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── DataConnectionLayer-001: subscribe must not mutate actor state off-thread ──
private static async Task<string> DelayedSubscribeAsync()