[archreview Crit-1/SBR] Non-transparent redundancy: crashing the OLDEST Akka node does not fail over #459
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?
Status: pre-registered user-deferred design gap. Redundancy failover works when the non-oldest node is lost, but with the Akka Split-Brain-Resolver topology, crashing the oldest cluster node does not trigger failover to the survivor — the pre-registered SBR topology gap.
Source of record:
archreview/plans/STATUS.md(SBR topology note) and the Crit-1 SBR premise follow-up.Acceptance:
Topology options + tradeoff (drafted 2026-07-15)
Premise correction (important — read first)
This issue was filed carrying the pre-03/S1 concern. In current master the premise "crashing the oldest doesn't fail over" is not accurate at the cluster level:
active-strategy = keep-oldest,keep-oldest.down-if-alone = on,stable-after = 15s(akka.conf), reinforced by the explicitKeepOldestOption { DownIfAlone = true }inBuildClusterOptions.HardKillFailoverTests.Hard_kill_of_oldest_node_fails_over_to_survivor_via_split_brain_resolverhard-kills the oldest/seed node A (abrupt terminate, no graceful Leave) and asserts node B becomes the soledriverrole-leader — it passes (~36 s). Its negative control confirms only an explicitdowning-provider-class = ""(NoDowning) breaks it (see FOLLOWUP-11: SBR was already active on master; keep-oldest+down-if-alone was never NoDowning).So the topology decision the acceptance asks for ("keep-oldest-as-leader vs. SBR strategy change") is effectively already made and correct — keep-oldest is the right strategy for a 2-node pair, and oldest-loss role-leader failover works.
The genuine residual
The in-process test asserts only the Akka role-leader handoff, with the no-op address-space sink (no real SDK). It does not exercise the client-visible ServiceLevel byte handover (survivor follower 240 → leader 250) or single
/alertsrows (Primary-gate dedup) after the oldest crashes. That is exactly the acceptance item's second half — "prove on the 2-node rig (ServiceLevel handover + single /alerts)" — a live verification on a real 2-container rig, not a topology redesign.Options
A — Keep
keep-oldest+down-if-alone(RECOMMENDED). No code change. Correct for 2 nodes: on a clean 1-1 partition the oldest (long-running primary) survives and the younger side downs itself within ~stable-after; on a hard crash of the oldest,down-if-alonelets the lone survivor take over (proven in-process).keep-majority/static-quorumare wrong for two nodes (no majority in a 1-1 split).down-if-aloneresolves both toward survivor availability (a partitioned-but-alive oldest yields). Acceptable here given the non-transparent shared-DB model + the Primary data-plane gate (PrimaryGatePolicy) that already prevents dual-primary device writes/alert duplication.B — 3-node cluster with a lightweight witness/lighthouse +
keep-majority. Add a 3rd Akka member with no driver/OPC role (a tiny arbiter process); switchactive-strategytokeep-majority. 2-of-3 is always a majority, so any single-node loss (oldest included) leaves a clear majority that survives + re-elects — removes the keep-oldest crash-vs-partition ambiguity entirely.C — DB-lease arbiter (decouple Primary from Akka oldest). Reintroduce a Primary lease backed by the shared Config DB (a SQL distributed lock / lease row); a node is Primary only while it holds the lease, and the survivor acquires it after TTL on oldest-loss. (v1 had
ApplyLeaseRegistry; v2 replaced it with Akka role-leader.)DbReachable, so it's a natural external arbiter and is split-brain-safe (lease = single source of truth) — but this reintroduces v2-removed lease machinery, adds code, and couples failover latency to the lease TTL + DB availability.Recommendation
Keep Option A — the topology is already correct and oldest-loss role-leader failover is proven. The only genuine open work is the live 2-container verification (ServiceLevel handover + single
/alerts), which is a test/ops task, not a redesign. Escalate to B only to remove the 2-node keep-oldest CAP caveat, or C only if an arbiter independent of Akka membership is desired.⚠️ CORRECTION — the premise is REAL; Option A is DISPROVEN (live 2-container rig)
My previous comment called the premise "stale" based on the in-process
HardKillFailoverTestspassing. A live verification on a real 2-container rig proves that test misleading and confirms #459. (Artifact:archreview/plans/artifacts/459-oldest-crash-live-finding-2026-07-15.md, master via PR #462.)What the live rig showed (reproduced twice, deterministic)
Server.ServiceLevel(i=2267) central-1 (oldest) = 250 (leader), central-2 = 240 (follower).docker killthe oldest (central-1, true process death) → central-2 downs ITSELF:opc.tcp://localhost:4841→ Connection refused. No survivor, no failover, total outage.Why (textbook, not a misconfig)
keep-oldestkeeps the partition containing the oldest and downs the other side. When the oldest crashes, the younger survivor is "the side without the oldest" → it self-downs.down-if-alonedoes not rescue it — that knob only makes the OLDEST down itself when it's isolated-but-alive; a crashed oldest can't run it. This is the documented 2-node keep-oldest limitation.Why the in-process test gave false confidence
HardKillFailoverTestsonly callsprovider.Transport.Shutdown()— node A's ActorSystem stays alive (a transport partition with the oldest still running), NOT a process crash. It exercises a different scenario than a real kill. → test-quality follow-up: make it a real process kill or reframe what it proves.Consequence — Option A is out
A 2-node cluster fundamentally cannot auto-survive an arbitrary single-node crash under any SBR strategy without a tiebreaker. The fix must be:
keep-majority(survives any single crash), orAcceptance (updated):
✅ RESOLUTION — 2-node recovery works (ScadaBridge parity); B/C RETRACTED (master
57003a23, PR #464)My "Option A disproven → need a witness/lease topology change (B/C)" was wrong and is retracted. The sister ScadaBridge project runs the same 2-node
keep-oldestand recovers fine. The gap was never the SBR strategy — it was missing restart supervision + a recovery watchdog, plus an inaccurate doc.What was actually happening
keep-oldestdowns the lone survivor (DownReachable including myself) — inherent (itsdown-if-alonerescue needs ≥2 survivors). Recovery is exit-and-rejoin:run-coordinated-shutdown-when-downterminates the node, the service supervisor restarts it, and it re-forms/rejoins.restart:policy (and CLIdocker killdoesn't trigger docker's restart, unlike a real crash). Production already has it —Install-Services.ps1setssc.exe failure OtOpcUaHost … restart/5000/restart/30000/restart/60000.Fix shipped (ScadaBridge parity)
ActorSystemTerminationWatchdog(Host, afterAddAkka): on an unexpectedActorSystem.WhenTerminated, callsIHostApplicationLifetime.StopApplication()so the process exits (supervisor restarts it) instead of idling with a dead actor system. 3 unit tests.restart: unless-stoppedon the host anchor + both peers inSeedNodesso a restarted node re-forms via either peer.docs/Redundancy.md: rewrote the recovery section — younger-loss = in-place fast failover; oldest-loss = exit-and-rejoin under supervision (not in-place); the three requirements (supervisor + watchdog + both-node seeds); flaggedHardKillFailoverTestsas non-representative (Transport.Shutdownkeeps the ActorSystem alive, not a real crash).Cluster.Tests 29/29, watchdog 3/3, full solution builds.
Remaining (live gate)
One item pending: a live end-to-end re-verify with the new watchdog image on the 2-container rig (kill oldest → watchdog exits survivor → supervisor auto-restarts both → auto-recover, no manual step). Currently blocked — the host docker VM is out of disk (OrbStack down). To run: free disk, restart OrbStack, then
docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2 traefik+up cluster-seed, baselinei=2267on :4840/:4841,docker killcentral-1, confirm auto-recovery.Acceptance:
keep-oldest; recovery is exit-and-rejoin under supervision (documented).✅ Live re-verify DONE — closing #459 (watchdog image, 2-container rig)
Ran the end-to-end recovery on the freshly-built watchdog image:
Server.ServiceLevel(i=2267) central-1 (oldest) = 250 (leader), central-2 = 240 (follower); bothrestart: unless-stopped.docker killcentral-1 (oldest) → central-2:SBR took decision DownReachable … including myself→CoordinatedShutdown … ClusterDowningReason … Exiting with [ExitCode:3].restartCount=1) via the restart policy — no manual step.The
ActorSystemTerminationWatchdogcorrectly stayed silent here —run-coordinated-shutdown-when-downalready initiated the process exit (cancellingApplicationStopping), so the watchdog's guard suppressed a redundantStopApplication. It is the safety net for the case coordinated-shutdown does not exit the process (the "idles forever with a dead actor system" case ScadaBridge documented); its logic is unit-covered (3/3).Notes / honest caveats:
docker start central-1: CLIdocker killdoes not trigger docker's restart policy (a test-harness artifact) — a real crash/OOM does, and productionsc.exe failure OtOpcUaHostrestarts it. So in production both nodes auto-restart.All acceptance items met:
keep-oldest; recovery = exit-and-rejoin under supervision (docs/Redundancy.md).restart: unless-stopped+ both-node seeds (master57003a23).Closing as resolved.