From a61e041e58a0db47b3465c2b650f39124e70ee86 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 13:07:56 -0400 Subject: [PATCH] feat(health)!: active node is the oldest Up member, not the leader (0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the two apps' private replacements for ActiveNodeHealthCheck into one shared primitive, and retires the leader/RoleLeader selection they were written to avoid. Both Akka consumers had already hand-rolled a replacement rather than use this package (ScadaBridge OldestNodeActiveHealthCheck, OtOpcUa ClusterPrimaryHealthCheck), so the entire active-node surface here — ActiveNodeHealthCheck, AkkaActiveNodeGate — had ZERO consumers. It was not merely unused: it was avoided, twice, for the same reason. Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered. The two agree on a freshly-formed cluster, which is why single-node and happy-path tests never caught it. They diverge permanently after any restart: the restarted node rejoins as the youngest but keeps its address, so if it holds the lower address it becomes leader while the singletons — and all the work they own — stay on the other node. New ClusterActiveNode is the single implementation: oldest Up member, optional role scope, plus role-preference resolution for a fused node that must answer for the role its singletons are pinned to. ActiveNodeHealthCheck and AkkaActiveNodeGate both delegate to it, so an endpoint gate and the /health/active probe an orchestrator routes by cannot disagree. BREAKING (behaviour, not signature): - Selection is by age, not leadership. - The role-filtered mode no longer reports Healthy for a node LACKING the role. That "not applicable => Healthy" made the tier answer 200 on every node and silently broke leader-pinning (lmxopcua#494); it is now Unhealthy by default, overridable via NoActiveRoleStatus. This case is reachable, not defensive — OtOpcUa's RoleParser admits dev-only and cluster-role-only nodes. - Identity compares UniqueAddress, so a node restarted on the same host:port is correctly a different member during the overlap. Results now carry activeRole/selfAddress/activeNode in the 0.2.0 per-entry data object, so a standby reports WHO is active and a dashboard can render a pair from either node's payload alone. Tests: 45 Akka (was 39), 76 total. The age-vs-address divergence is pinned against a real two-node cluster built so the oldest member is not the lowest-addressed one, with a fixture sanity check so it cannot pass for the wrong reason. --- ZB.MOM.WW.Health/Directory.Build.props | 2 +- .../ActiveNodeHealthCheck.cs | 194 +++---- .../ActiveNodeHealthCheckOptions.cs | 37 ++ .../AkkaActiveNodeGate.cs | 49 +- .../ClusterActiveNode.cs | 133 +++++ .../ActiveNodeDecisionTests.cs | 139 ----- .../ActiveNodeStartupSafetyTests.cs | 113 ++++ .../ActiveRoleResolutionTests.cs | 56 ++ .../ClusterActiveNodeTests.cs | 237 ++++++++ akka_failover.md | 531 ++++++++++++++++++ .../2026-07-22-initjoin-selfform-fallback.md | 104 ++++ 11 files changed, 1332 insertions(+), 263 deletions(-) create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheckOptions.cs create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ClusterActiveNode.cs delete mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeStartupSafetyTests.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveRoleResolutionTests.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ClusterActiveNodeTests.cs create mode 100644 akka_failover.md create mode 100644 docs/plans/2026-07-22-initjoin-selfform-fallback.md diff --git a/ZB.MOM.WW.Health/Directory.Build.props b/ZB.MOM.WW.Health/Directory.Build.props index 667743a..0985c8f 100644 --- a/ZB.MOM.WW.Health/Directory.Build.props +++ b/ZB.MOM.WW.Health/Directory.Build.props @@ -5,7 +5,7 @@ enable enable latest - 0.2.1 + 0.3.0 true