fix(dcl): push tag-resolution counts on first subscribe (register row 55)
A site health report showed tagResolutionCounts 0/0 for a connection whose dataConnectionTagQuality on the SAME report read good:2 — reproduced live on the docker rig against site-a's rig-probe-1 instance (2 Good OPC UA tags on "OPC PLC Simulator"), on the ACTIVE node's own report. Not a standby-shadowing or keying/removal problem, and not a serialization problem: both dictionaries live on one collector instance on one node, and the CentralControlDtoMapper round-trip is correct in both directions. The actor's own derived counts (TotalSubscribedTagCount / ResolvedTagCount) were right the whole time — GetHealthReport replied 2/2. Root cause: HandleSubscribeCompleted — the path that registers tags on a first, healthy subscribe — mutated _instancesByTag and _subscriptionIds but never pushed to ISiteHealthCollector. The only pushes were BecomeConnected's (fires before any instance subscribes, so its 0/0 is legitimate), the batch apply (reconnect re-subscribe / resolution probe) and unsubscribe (teardown). A connection whose tags all resolve first time reaches none of those, and ScheduleTagResolutionRetry cancels the retry timer outright when nothing is unresolved — so the connect-time 0/0 was never superseded and stood forever. The blind spot was therefore worst on a perfectly healthy connection, which is exactly when the "device still booting / tag path unresolved" diagnostic should read clean rather than empty. Any reconnect masked it (the batch path pushes), which is why it presented only on a clean first deploy. Fix: push the derived counts at the end of the first-subscribe apply, mirroring the existing push site in HandleBatchSubscribeCompleted. No semantic change to the null/no-data convention — the map entry already existed, its values were simply stale zeros. Tests: fail-before regression test pins the exact mechanism (NSubstitute records a single UpdateTagResolution(name, 0, 0) pre-fix while the actor reports 2/2). DCL 320, HealthMonitoring 97, SiteRuntime 605, Communication 717 all green; slnx builds clean. Docs: Component-DataConnectionLayer.md said the counts were "derived at report time", which implies a pull that cannot go stale and papered over this bug. Corrected to state they are pushed, name the four push sites, and record why a healthy connection was the worst case.
This commit is contained in:
@@ -1770,4 +1770,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user