/health/active returns 200 on every node — the tier cannot identify the active node #494
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
/health/activereturns 200 on every node, including standbys and nodes that are not the redundancy Primary. A consumer cannot use it to tell which node of a pair is active, which is what the tier exists to answer.Found during the live acceptance run for the family overview dashboard (
scadaproj docs/plans/2026-07-22-overview-dashboard-impl-plan.md, Phase 4). The dashboard reads/health/activeand maps 200 → Active, 503 → Standby — the mappingMapZbHealthimplies, and the one ScadaBridge already satisfies. Against OtOpcUa it therefore shows all six rig nodes as Active.Live evidence
Rig:
docker-dev, six nodes, three healthy 2-node meshes (2 members · 0 unreachable, both members of each pair agreeing on one leader). Build =feat/mesh-phase6+ the Health 0.2.0 bump.For contrast, ScadaBridge on the same rig host:
Why every node answers 200
Two independent causes, both in
src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/HealthEndpoints.cs:1. On the standby admin, the check returns
Degraded, andDegradedis a 200.The shared
ActiveNodeHealthCheckrole-filtered mode maps "carries the role but is not the role leader" toHealthStatus.Degraded(ActiveNodeDecision.Evaluate).MapZbHealthuses the ASP.NET default status mapping — Healthy/Degraded → 200, Unhealthy → 503 — socentral-2is indistinguishable fromcentral-1over HTTP. Only the body differs.2. On driver-only nodes the check passes vacuously.
Same
Evaluate:if (!hasRole) return HealthStatus.Healthy. The four site nodes carrydriver+cluster-SITE-*and noadminrole, so they report Healthy with the description "Active for role 'admin' (or not a role member)". The tier says nothing at all about them.Note this is the shared library behaving exactly as documented for its role-filtered constructor — this is about which question OtOpcUa asks it, not a bug in
ZB.MOM.WW.Health.Akka.The deeper mismatch
The check is scoped to
RoleLeader("admin"). PerCLAUDE.md, the redundancy Primary is now the oldest Updrivermember, per applicationCluster(NodeRedundancyState.IsDriverPrimary,ServiceLevel250 vs 240) — and the same doc records that oldest-Up andRoleLeader(lowest address) diverge after any node restart. So even for the central pair,admin-leaderis not asking about the node whoseServiceLevelreads 250.After Phase 6 there is one Primary per
Cluster, so the tier should be able to answer per-cluster: exactly one 200 inMAIN, one inSITE-A, one inSITE-B.Suggested direction (not prescriptive)
Make
/health/activemean "this node is the redundancy Primary for itsCluster":IsDriverPrimaryrather thanRoleLeader("admin").Unhealthy— notDegraded— on a non-primary node, so the tier is a 503. If the role-filtered shared mode is still wanted elsewhere,ZB.MOM.WW.Healthalso offers the role-less mode, which already returns Unhealthy for a non-leader (that is the mode ScadaBridge uses, and why it reads correctly).Impact to check before changing this
docker-dev/traefik-dynamic.ymluses/health/activeas its load-balancer health check for the central UI:Today both central nodes pass, so Traefik load-balances the AdminUI across the pair (with a sticky cookie). If a standby starts returning 503, Traefik will drop it from the pool and send all UI traffic to the primary. That may well be desirable — the deploy singleton lives on one node — but it is a behaviour change, and the same pattern may exist in
scripts/install/traefik-dynamic.yml. Worth deciding deliberately rather than discovering.Not blocking the dashboard
The overview dashboard renders OtOpcUa correctly in every other respect — leader chip per cluster group, member counts, no split-brain, per-check detail. Only the Active/Standby badge is wrong, and it is wrong because it faithfully reports what the endpoint says.
Traefik half fixed —
ZB.MOM.WW.Health0.2.1Root cause was in the shared library, not here.
ActiveNodeDecision.EvaluatereturnedDegradedfor "carries the role but is not the role leader", andMapZbHealthmaps Degraded to 200. The shared health spec has always said the active tier "Fails (503) on a standby or role-member-but-not-leader node" — so the library contradicted its own spec, and this repo's docs (docs/ServiceHosting.md,docs/v2/Architecture-v2.md, the 2026-05-26 alignment design's "200 only on the Admin-role leader; 503 elsewhere") were describing behaviour that never shipped.Fixed in
ZB.MOM.WW.Health0.2.1 (published to the Gitea feed); this repo bumped in8dd9da7d. Only OtOpcUa is affected — it is the sole consumer of the role-filtered mode.Live verification on docker-dev
With the MAIN pair genuinely formed (
memberCount2, both members naming one leader):The failover drill in
docker-dev/README.mdstep 2 — which had never actually exercised anything, since both backends always passed — now works:central-1at 15:27:41; Traefik had flipped tocentral-2by 15:28:01 (< 20 s)http://localhost:9200/answered 200 continuously across the swap, no outagecentral-1reclaimed the role-leader and Traefik flipped backREADME corrected on two counts: exactly one backend is UP by design, and step 3's claim that "central-2 keeps the leader role" is wrong —
RoleLeaderis the lowest-address member, socentral-1deterministically reclaims it.Still open in this issue
Driver-only nodes. The check is scoped to
RoleLeader("admin")and returns Healthy for any node lacking the role, so all four site nodes still answer/health/active200:The real per-
Clusterredundancy Primary is the oldest Updrivermember (IsDriverPrimary, ServiceLevel 250 vs 240), which this tier never consults. Traefik doesn't route to site nodes so nothing is broken operationally, but any consumer selecting the active node by this tier — the overview dashboard does — still reads every site node as Active.Cold-boot caveat worth recording
Starting both central nodes simultaneously from stopped can leave each self-forming its own 1-member cluster (self-first seed lists), in which case both ARE their own role-leader and both correctly answer 200. Check
entries["akka"].data.memberCounton/health/readybefore concluding the fix regressed. Restarting either node makes it join the survivor. Related:scadaproj docs/plans/2026-07-22-initjoin-selfform-fallback.md.Closed — site nodes fixed, and the earlier fix corrected
45504861replaces the sharedActiveNodeHealthCheck(role: "admin")with a localClusterPrimaryHealthCheck(cluster-primary).One rule: this node is active iff it is the oldest Up member carrying its own active role, where the active role is
adminwhen the node has it anddriverotherwise.admin→ Traefik pins the AdminUI to the node hosting the cluster singletons.driver→ exactlySelectDriverPrimary, the same election behindIsDriverPrimaryand the ServiceLevel 250/240 split. The tier and the ServiceLevel can no longer disagree.Per-mesh scoping is free after Phase 6 —
ClusterState.Membersalready contains only the node's ownCluster, so exactly one node answers 200 perClusterby construction.SelectDriverPrimaryis generalised toSelectOldestUpMemberOfRoleso the age-ordering rule has a single implementation.The RoleLeader half was not theoretical
Worth recording, because the earlier fix (
8dd9da7d,ZB.MOM.WW.Health0.2.1) made the tier a real 503 but still selected byRoleLeader. On the rebuilt rig the two orderings diverge right now:So 0.2.1 alone would have pinned Traefik to
central-1— the node not hosting the work. Exactly the failure modeRedundancyStateActor.SelectDriverPrimarywas written to avoid, reappearing in the health tier. With this change Traefik correctly routes tocentral-2.Live verification
Exactly one 200 per mesh:
cluster-primaryon site-a-1:The overview dashboard now renders one Active + one Standby for all three OtOpcUa
Clusters.Follow-up worth considering (not filed)
Two of the four family apps now need "oldest Up member of role X" and both had to avoid the shared
ActiveNodeHealthCheckto get it — ScadaBridge wroteOldestNodeActiveHealthCheckfor the same reason (leadership diverges from singleton host), and this repo now hasClusterPrimaryHealthCheck. That is a candidate for promotion intoZB.MOM.WW.Health.Akkaas an oldest-member mode, retiring theRoleLeader-based one. Left alone here rather than designing a shared API off two samples.