merge: tag-resolution counts fixed on first subscribe (register row 55) — HandleSubscribeCompleted was the missing fourth UpdateTagResolution push site, so a perfectly healthy connection reported 0/0 forever

This commit is contained in:
Joseph Doherty
2026-08-15 17:56:14 -04:00
3 changed files with 74 additions and 1 deletions
@@ -1792,4 +1792,61 @@ public class DataConnectionActorTests : TestKit
// Belt-and-braces: after the fresh value, no further (stale) update may arrive.
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
// ── Deferred register row 55: first-subscribe resolution counts never reached the collector ──
[Fact]
public async Task FirstSubscribe_AllTagsResolve_PushesResolutionCountsToHealthCollector()
{
// Regression test for deferred-work register row 55, reproduced live on the docker
// rig: site-a's health report showed
// tagResolutionCounts = { "OPC PLC Simulator": { totalSubscribed: 0, resolved: 0 } }
// while dataConnectionTagQuality on the SAME report (same collector, same active
// node) said good: 2.
//
// Mechanism: the ONLY health push on the happy path was BecomeConnected's, which
// fires BEFORE any instance has subscribed and is therefore a legitimate 0/0.
// HandleSubscribeCompleted — the path that registers tags on a first, healthy
// subscribe — mutated _instancesByTag and _subscriptionIds but pushed nothing, so
// that 0/0 was never superseded. The two sites that DO push are
// HandleBatchSubscribeCompleted (reconnect re-subscribe / resolution probe) and
// HandleUnsubscribe (teardown); neither runs on a connection whose tags all
// resolve first time, and ScheduleTagResolutionRetry cancels the retry timer
// outright when _unresolvedTags is empty. Result: a fully healthy connection
// reported 0/0 forever, blinding the "device still booting / tag path unresolved"
// diagnostic exactly when it is supposed to read clean.
//
// The actor's OWN derived counts were correct the whole time — that asymmetry is
// the signature of the bug, so this test pins both halves.
_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-" + ci.ArgAt<string>(0)));
_mockAdapter.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ReadResult(false, null, null));
var actor = CreateConnectionActor("row55-first-subscribe");
await Task.Delay(300);
// BecomeConnected has now pushed its legitimate 0/0 (no tags subscribed yet).
_mockHealthCollector.Received().UpdateTagResolution("row55-first-subscribe", 0, 0);
// One instance subscribes to two tags; both resolve on the first attempt, so the
// batch/probe path never runs — exactly the rig's rig-probe-1 shape.
actor.Tell(new SubscribeTagsRequest(
"c1", "inst1", "row55-first-subscribe",
["sensor/temp", "sensor/pressure"], DateTimeOffset.UtcNow));
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
// The actor's own derived counts are correct — this half passed pre-fix too.
actor.Tell(new DataConnectionActor.GetHealthReport());
var report = ExpectMsg<DataConnectionHealthReport>(TimeSpan.FromSeconds(5));
Assert.Equal(2, report.TotalSubscribedTags);
Assert.Equal(2, report.ResolvedTags);
// The collector must agree. Pre-fix this is the failing assertion: the only call
// ever recorded was the (0, 0) from BecomeConnected.
_mockHealthCollector.Received().UpdateTagResolution("row55-first-subscribe", 2, 2);
}
}