fix(overview): rank activeNode over leader for the Active chip
The dashboard derived every leader chip from AkkaClusterHealthCheck's `leader` data — ClusterState.Leader, the LOWEST-ADDRESSED Up member. That is not the node in charge. The node holding the singletons, gated for writes, and answering 200 on /health/active is the OLDEST Up member. The two agree only until some node restarts, after which the restarted node rejoins youngest but keeps its address. Observed live on the rebuilt OtOpcUa rig during the Health 0.3.0 work: leader central-1, active node central-2. The dashboard would have put the Active chip on central-1 while /health/active said central-2 — the chip contradicting the tier it exists to visualise. The Phase 4 acceptance run missed it because the rig had just been formed, the one state in which the two orderings agree. Health 0.3.0's active tier now publishes `activeNode` on every member, including standbys, so the fix is to prefer it and keep `leader` as the fallback for an instance on an older Health or whose active tier was not probed. The instance card now shows `active` and `leader` side by side rather than swapping one for the other: they are legitimately different nodes, and showing both turns a silent contradiction into something readable at 3am. Tests: 177 (+3) — precedence when the two disagree, a standby naming the active node rather than itself, and the fallback still working with no activeNode.
This commit is contained in:
@@ -221,4 +221,63 @@ public class LeaderResolverTests
|
||||
|
||||
Assert.Equal("akka.tcp://otopcua@node-a:4053", LeaderResolver.LeaderReportedBy(instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveNode_WinsOverLeader_WhenTheyDisagree()
|
||||
{
|
||||
// The two are DIFFERENT NODES after any restart: leader is the lowest-addressed Up member,
|
||||
// the active node is the oldest. Observed live on the rebuilt OtOpcUa rig — leader central-1,
|
||||
// active central-2 — so ranking leader first would put the Active chip on the standby, which
|
||||
// is the same class of error as lmxopcua#494 rendered in the UI instead of in routing.
|
||||
var body = HealthBody.WithData(
|
||||
"Healthy",
|
||||
("akka", "Healthy", new Dictionary<string, object>
|
||||
{
|
||||
["leader"] = "akka.tcp://otopcua@central-1:4053",
|
||||
}),
|
||||
("cluster-primary", "Healthy", new Dictionary<string, object>
|
||||
{
|
||||
["activeNode"] = "akka.tcp://otopcua@central-2:4053",
|
||||
}));
|
||||
var report = System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(body)!;
|
||||
var instance = Snapshots.Instance("central-1", "MAIN", "http://central-1:8080") with { Report = report };
|
||||
|
||||
Assert.Equal("akka.tcp://otopcua@central-2:4053", LeaderResolver.LeaderReportedBy(instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveNode_ReportedByAStandby_NamesTheActiveNode_NotItself()
|
||||
{
|
||||
// Every member publishes who IS active, so a standby votes for the same address the active
|
||||
// node does. That is what lets the group resolve with one unanimous chip instead of a
|
||||
// manufactured disagreement, and it is why the standby's payload alone is enough.
|
||||
var body = HealthBody.WithData(
|
||||
"Unhealthy",
|
||||
("cluster-primary", "Unhealthy", new Dictionary<string, object>
|
||||
{
|
||||
["activeNode"] = "akka.tcp://otopcua@site-a-1:4053",
|
||||
["selfAddress"] = "akka.tcp://otopcua@site-a-2:4053",
|
||||
}));
|
||||
var report = System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(body)!;
|
||||
var instance = Snapshots.Instance("site-a-2", "SITE-A", "http://site-a-2:8080") with { Report = report };
|
||||
|
||||
Assert.Equal("akka.tcp://otopcua@site-a-1:4053", LeaderResolver.LeaderReportedBy(instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leader_IsStillUsed_WhenNoActiveNodeIsPublished()
|
||||
{
|
||||
// An instance on Health < 0.3.0, or one whose active tier was not probed, publishes only
|
||||
// `leader`. Showing it beats showing nothing, and it keeps the pre-0.3.0 behaviour intact.
|
||||
var body = HealthBody.WithData(
|
||||
"Healthy",
|
||||
("akka-cluster", "Healthy", new Dictionary<string, object>
|
||||
{
|
||||
["leader"] = "akka.tcp://scadabridge@central-a:4053",
|
||||
}));
|
||||
var report = System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(body)!;
|
||||
var instance = Snapshots.Instance("central-a", "central", "http://central-a:8080") with { Report = report };
|
||||
|
||||
Assert.Equal("akka.tcp://scadabridge@central-a:4053", LeaderResolver.LeaderReportedBy(instance));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,31 @@ internal static class HealthBody
|
||||
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a body whose entries carry arbitrary <c>data</c>, for the cases where the key under
|
||||
/// test is not <c>leader</c> — notably the active tier's <c>activeNode</c>.
|
||||
/// </summary>
|
||||
public static string WithData(string status, params (string Name, string Status, Dictionary<string, object>? Data)[] entries)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
status,
|
||||
totalDurationMs = 1.5,
|
||||
entries = entries.ToDictionary(
|
||||
e => e.Name,
|
||||
e => (object)new
|
||||
{
|
||||
status = e.Status,
|
||||
description = $"{e.Name} says {e.Status}",
|
||||
durationMs = 0.5,
|
||||
data = e.Data,
|
||||
},
|
||||
StringComparer.Ordinal),
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds <see cref="InstanceSnapshot"/> values for the pure-logic tests.</summary>
|
||||
|
||||
Reference in New Issue
Block a user