fix(overview): locate the cluster view by data key, not by check name

Found by the live rig, and invisible to every unit test that existed.

LeaderResolver and InstanceCard.ClusterDataLine both looked the cluster view up
under the check name "akka-cluster". ScadaBridge registers the shared
AkkaClusterHealthCheck under that name; OtOpcUa registers the SAME check as
"akka". So once the OtOpcUa rig was rebuilt on Health 0.2.0 and started
publishing data.leader, its groups still rendered with no leader chip and no
evidence line - and a group with no votes is a legitimate state, so nothing
looked wrong. ScadaBridge's four groups were fully populated the whole time,
which is what made it look like the feature worked.

Both now find the entry by the DATA KEY ("leader"), which is the part of the
contract the shared check actually owns rather than the part each consumer names
for itself.

Regression tests: the leader is found under akka-cluster, akka, and an arbitrary
future name; and an entry that publishes data WITHOUT a leader (ScadaBridge's
localdb check, whose data carries replication counters) does not get picked in
preference to the one that answers the question. Every prior fixture used
ScadaBridge's name, which is precisely why this got through.

After the fix all seven cluster groups render a leader chip with zero
split-brain flags - design section 9 check 2 now passes for BOTH products.

174 tests, 0 warnings.
This commit is contained in:
Joseph Doherty
2026-07-24 10:38:39 -04:00
parent 41013dcab3
commit d95292c95d
5 changed files with 88 additions and 15 deletions
@@ -182,4 +182,43 @@ public class LeaderResolverTests
{
Assert.Equal(expected, LeaderResolver.HostOf(address));
}
[Theory]
[InlineData("akka-cluster")] // ScadaBridge
[InlineData("akka")] // OtOpcUa
[InlineData("cluster-view")] // any future app
public void Leader_IsFoundWhateverTheCheckIsCalled(string checkName)
{
const string NodeALeader = "akka.tcp://otopcua@node-a:4053";
// The two apps register the SAME shared AkkaClusterHealthCheck under different names, so a
// resolver keyed on one name renders no leader chip at all for the other — silently, since
// a group with no votes is a legitimate state. That is exactly how this shipped past
// review and unit tests and was only caught on a live rig.
var groups = LeaderResolver.Resolve(
[
Snapshots.Instance("node-a", "pair", "http://node-a:8080", leader: NodeALeader, clusterCheckName: checkName),
Snapshots.Instance("node-b", "pair", "http://node-b:8080", leader: NodeALeader, clusterCheckName: checkName),
]);
var group = Assert.Single(groups);
Assert.Equal("node-a", group.LeaderInstance);
Assert.False(group.Disagreement);
}
[Fact]
public void Leader_IgnoresChecksThatPublishDataWithoutALeader()
{
// ScadaBridge's site nodes publish a localdb check whose data carries replication counters
// and no leader. Scanning entries must pick the one that actually answers the question,
// not merely the first one carrying a data object.
var body = HealthBody.Ready(
"Healthy",
("localdb", "Healthy", null),
("akka", "Healthy", "akka.tcp://otopcua@node-a:4053"));
var report = System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(body)!;
var instance = Snapshots.Instance("node-a", "pair", "http://node-a:8080") with { Report = report };
Assert.Equal("akka.tcp://otopcua@node-a:4053", LeaderResolver.LeaderReportedBy(instance));
}
}