The InitJoin self-form watchdog (a timer calling Cluster.Join(SelfAddress) after a window) was implemented and REJECTED after live testing: it sits outside Akka's join handshake, so on a routine standby restart — peer alive, join stalled behind removal of the node's own stale incarnation — it fires mid-join, abandons the in-flight join and forms a permanent second cluster at the same address (measured: still split after 90s; OtOpcUa hit it live in its manual-failover drill). The safety argument 'a booting node only self-forms when no seed answers InitJoin' is true of Akka's NATIVE first-seed rule (FirstSeedNodeProcess, inside the handshake) and FALSE of the watchdog, which can fire while a seed is answering. That reasoning error is now called out explicitly so no future plan re-derives the pattern. - docs/plans/2026-07-22-initjoin-selfform-fallback.md: dated correction banner (history preserved) + the two surviving false claims struck in place (behavior-spec row and the Mechanism section's 'races are benign'); points at ScadaBridge Component-ClusterInfrastructure.md as the surviving design. - akka_failover.md 6.1: shipped design stated as self-first seed ordering plus the opt-in, default-OFF simultaneous-cold-start bootstrap guard; added a never-re-derive-the-watchdog note. 6.2: two items my 6.1 edit contradicted are marked closed (OtOpcUa auto-down live gate, mesh election scope — both done 2026-07-24). - CLAUDE.md: ScadaBridge issue #14 is CLOSED (2026-07-24), not open — v3 dual-namespace live gates all passed, ScadaBridge consumes OtOpcUa v3 end-to-end.
16 KiB
InitJoin Self-Form Fallback (Cluster:SelfFormAfter) + Manual Failover Control — Shared Design & Index
For Claude: This is the family-level DESIGN + INDEX document. The executable per-repo plans live in the repos (split 2026-07-22 — the OtOpcUa plan is expected to grow and has a reserved-additions section):
- ScadaBridge:
~/Desktop/ScadaBridge/docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md(10 tasks: fallback 1–7, manual-failover UI 8–9, optional site-pair failover 10)- OtOpcUa:
~/Desktop/OtOpcUa/docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md(7 tasks + "Reserved for additions" section)- OtOpcUa mesh-alignment PROGRAM (added 2026-07-22):
~/Desktop/OtOpcUa/docs/plans/2026-07-22-per-cluster-mesh-program.md— Phases 1–7 moving OtOpcUa to ScadaBridge's mesh shape (one 2-node Akka mesh per application Cluster, ClusterClient/gRPC/fetch transports, driver nodes off the ConfigDb), driven by the co-location decision: OtOpcUa pairs run on the SAME Windows VMs as the ScadaBridge nodes. The selfform plan above is its prerequisite. Design:OtOpcUa/docs/plans/2026-07-21-per-cluster-mesh-design.md.Execute each with superpowers-extended-cc:executing-plans in that repo. The two plans are fully independent and can run in parallel. Only Task C1 below executes from this file.
Goal: (1) Either node of a 2-node pair can cold-start alone and become operational, unattended — closing the seed-node bootstrap gap in both ScadaBridge and OtOpcUa via a configurable, fast (default 10 s) InitJoin timeout that falls back to self-forming a cluster. (2) An admin-only "Trigger failover" control on each app's cluster-status page for operator-initiated, graceful role swaps.
⚠️ EXECUTION OUTCOME (2026-07-22) — design partially overturned
Both repo plans executed. The behavior-spec row "Window expires while a join is mid-handshake → benign race: Akka ignores
Joinonce joined" is FALSE — the watchdog sits outside Akka's join handshake, and aJoin(self)fired while a join is in flight (routine restart into a live peer, join stalled behind stale-incarnation removal) abandons the join and forms a permanent second cluster at the same address. Proven independently by a ScadaBridge test (split unhealed after 90 s) and by an OtOpcUa live island during the manual-failover drill.
- ScadaBridge rejected the watchdog entirely and shipped self-first seed ordering (each node lists itself as
seed-nodes[0]; Akka'sFirstSeedNodeProcessprovides the intended semantics race-free inside the handshake;StartupValidatorhard-gates the ordering). NoSelfFormAfteroption exists there. See its plan's "Part 1 as executed".- OtOpcUa first kept
SelfFormAfterbehind a TCP reachability guard, then converged the same day (lmxopcua @ 3f24d4d6):ClusterBootstrapFallback+SelfFormAfterdeleted, self-first ordering shipped with a conditionalAkkaClusterOptionsValidator(self must beseed[0]only IF self is a seed — site nodes exempt; identity onPublicHostname+port, not the 0.0.0.0 bind).- Both repos now share the identical mechanism. Everything merged + pushed 2026-07-22, including ScadaBridge Task 10 (site-pair failover relayed from the central UI).
- The design sections below are retained as the decision record — read them through this banner.
akka_failover.md§6.1 carries the corrected family-level account.
🛑 CORRECTION (2026-08-01) — the self-form watchdog is REJECTED, not shipped
Re-read of this doc found the false safety claim still sitting unmarked inside the design body (behavior-spec row "Window expires while a join is mid-handshake" and the Mechanism section's "Races are benign") even though the 2026-07-22 banner above overturns it. Both are now struck in place. Stating it plainly, once, so no future reader reconstructs the pattern from the surviving prose:
- The watchdog pattern was implemented and REJECTED after live testing. A timer that waits a window for membership and then calls
Cluster.Join(SelfAddress)is defective. On a routine standby restart — peer alive and answering, join stalled behind removal of the restarting node's own stale incarnation (the peer must down it, wait out the failure detector, then take a leader action) — the timer fires mid-join, abandons the in-flight join, and forms a second cluster at the same address. Measured on ScadaBridge: still split after 90 s — a routine restart converted into an outage of the previously-healthy node. OtOpcUa hit the same race live during its manual-failover drill.- The "only self-forms when no seed answers InitJoin" reasoning does NOT protect the watchdog. That property is true of Akka's native first-seed rule (
FirstSeedNodeProcess), which lives inside the join handshake and therefore knows whether a seed answered. The watchdog lives outside the handshake: its only signal is "am IUpyet?", which cannot distinguish no seed answered from a seed answered and the join is in flight. So the watchdog can and does fire while a seed is answering. Any doc, comment or plan that justifies a self-form timer with the InitJoin-answer argument is quoting the native rule's guarantee to excuse a mechanism that does not have it.- The surviving design is documented in ScadaBridge, not here. Authoritative source:
~/Desktop/ScadaBridge/docs/requirements/Component-ClusterInfrastructure.md→ Seed Node Ordering (self-first: every node lists ITSELF asseed-nodes[0]; onlyseed-nodes[0]may self-form, via Akka's ownFirstSeedNodeProcess;StartupValidatorhard-gates the ordering;SelfFirstSeedBootstrapTests) and Simultaneous cold start — the bootstrap guard (the separate, opt-in, default-OFFScadaBridge:Cluster:BootstrapGuard, Gitea #33, which decides the join order from a TCP reachability probe before a singleJoinSeedNodesand never re-forms mid-handshake — that last property is precisely what the watchdog lacked). Companion decision record:~/Desktop/ScadaBridge/docs/plans/2026-07-21-auto-down-availability-decision.md.- Neither repo ships the watchdog. ScadaBridge never merged it. OtOpcUa deleted
ClusterBootstrapFallback+SelfFormAfterthe same day; source re-verified 2026-08-01 — see the correction section in that repo's plan.Nothing below this banner is safe to execute as written. The Design sections are history.
Decision provenance: scadaproj/akka_failover.md §6.1 (decided 2026-07-22); design priority = availability over partition safety (memory ha-availability-over-partition-safety). Nodes share a datacenter/LAN → a live peer answers InitJoin in milliseconds, so 10 s is generous; operators tune per site.
Design
The defect being fixed
Akka.NET only allows the first-listed seed to self-join and form a new cluster. Every other node loops on InitJoin forever until some existing member answers. Both repos document this as the "registered outage gap": a non-first seed cold-starting while the first seed is down never becomes Up (recovery today = manual env-var override or reviving the first seed). ScadaBridge's ClusterOptions.SeedNodes doc comment even claims "either can start first and form the cluster" — which the deployed configs do not deliver (both nodes list node-a first). The plans make the claim true and fix the comment.
Behavior specification
| Scenario | Behavior with fallback |
|---|---|
| Peer alive (any boot order) | Normal seed join within ms — fallback never fires (membership arrives before the window) |
| Lone cold-start, node IS in its own seed list | After SelfFormAfter (default 10 s): warn log + Cluster.Join(SelfAddress) → Up alone, singletons start (min-nr-of-members=1) |
| Lone cold-start, node NOT in its own seed list | Fallback inert (info log at arm time); node waits for a seed — self-forming would island it. This is the OtOpcUa site-node topology (seeded only by central-1). |
| Peer boots after survivor self-formed | Peer's InitJoin reaches the self-formed node (it's in the peer's seed list) → joins as youngest/standby. No island. |
| Both cold-start simultaneously, mutually unreachable (boot-time partition) | Both self-form → two islands (same dual-active class auto-down already accepts; same recovery: restart one side). Mitigable with a staggered service start. |
SelfFormAfter = null or <= 0 |
Disabled — today's wait-forever behavior |
Join once the node has already joined a clusterCluster.Join(SelfAddress) is NOT ignored mid-handshake: it abandons the in-flight join and forms a permanent second cluster at the same address (ScadaBridge: still split after 90 s; OtOpcUa: live island during the manual-failover drill). This row is the whole reason the watchdog was rejected. |
Config surface (same key both apps — family convention, like SplitBrainResolverStrategy)
- ScadaBridge:
ScadaBridge:Cluster:SelfFormAfter→ClusterOptions.SelfFormAfter(TimeSpan?, default00:00:10), validated non-negative-or-null byClusterOptionsValidator. - OtOpcUa:
Cluster:SelfFormAfter→AkkaClusterOptions.SelfFormAfter(TimeSpan?, default00:00:10); disable-on-<=0semantics live in the fallback itself. - NOT a code constant (deliberate — see the
DowningStableAfterlesson,akka_failover.md§6.2).
Mechanism (both apps, deliberately duplicated like the termination watchdogs)
ClusterBootstrapFallback.Arm(system, options, logger), called at ActorSystem creation (ScadaBridge: inside AkkaHostedService.GetOrCreateActorSystem; OtOpcUa: an Akka.Hosting AddStartup task inside WithOtOpcUaClusterBootstrap, so production and test hosts arm identically):
SelfFormAfternull/≤0→ log info, return (disabled).- Self address not in own
SeedNodes(parsed viaAddress.Parse, exact equality) → log info, return (island guard). RegisterOnMemberUpcompletes a TCS; race it againstTask.Delay(window).- On window expiry (and system not terminated): prominent warn log +
Cluster.Join(cluster.SelfAddress).
Races are benign — Akka ignores FALSE (correction
2026-08-01). Step 3's success signal (Join once joined this incarnation.RegisterOnMemberUp) is outside Akka's join handshake and
cannot tell no seed answered from a seed answered and the join is in flight; step 4's
Cluster.Join(self) fired during TryingToJoin wins and islands the node. Do not implement this
mechanism. The InitJoin-answer guarantee belongs to Akka's FirstSeedNodeProcess, not to a timer
bolted alongside it — use self-first seed ordering (ScadaBridge
Component-ClusterInfrastructure.md → Seed Node Ordering).
Manual failover control (Part D design)
Mechanism — graceful Cluster.Leave, never Down. "Trigger failover" = issue Cluster.Leave(<current active/primary member's address>) from whichever node serves the UI (any member may initiate a leave for any member). The leaving node walks the graceful path: singleton hand-over via the cluster-leave phases (ScadaBridge additionally drains each singleton 10 s), its CoordinatedShutdown runs, the existing termination watchdog exits the process, the service supervisor restarts it, and it rejoins as the youngest member — a permanent role swap under the oldest-Up election, with no downing window and no dead interval beyond hand-over latency.
Shared rules:
- Admin-only: button renders inside the app's admin-policy
AuthorizeView(ScadaBridgeAuthorizationPolicies.RequireAdminon/monitoring/health; OtOpcUa the mutating-pages policy fromAdminUiPolicieson/clusters/{id}/redundancy). - Peer guard: disabled (with tooltip) when fewer than 2 Up members carry the relevant role.
- Confirmation dialog stating exactly what happens.
- Audited through each app's existing audit seam before the Leave (
cluster.manual-failover; ScadaBridge uses its CENTRAL audit writer, not the shared seam — dual-seam gotcha). - UX note (ScadaBridge): Traefik routes the UI to the active node, so triggering central failover drops the Blazor circuit; the page reconnects against the new active. The dialog warns about this.
- Scope note (OtOpcUa): until the per-cluster mesh lands, the driver-Primary election is mesh-wide — the button acts on THE Primary of the whole Akka cluster; the panel says so (mirrors the KNOWN LIMITATION in
docs/Redundancy.md). - Election parity: each service's target query is intentionally identical to the app's active/primary election (
ActiveNodeEvaluator.SelfIsOldestUp/RedundancyStateActor.SelectDriverPrimary), pinned by a parity test — the node acted on must be exactly the one hosting the singletons. - Self-form interplay: none — the restarted node rejoins via its peer (alive by definition here), so
SelfFormAfternever fires on this path.
Multi-node testing assessment (getakka.net/articles/testing/multi-node-testing.html)
Verdict: usable in principle, deliberately NOT adopted. The MultiNode TestKit (Akka.MultiNode.TestAdapter + Akka.Remote.TestKit + Akka.Cluster.TestKit) runs each RoleName as a separate process under dotnet test, with TestConductor able to Blackhole links and Exit nodes, synchronized by EnterBarrier. Reasons not to use it here:
- Everything this feature needs is testable in-process with real ActorSystems built from production bootstrap code (ScadaBridge
TwoNodeClusterFixture; OtOpcUaSplitBrainResolverActivationTestspattern). Same fidelity, no new runner. - Heavy project-level cost: dedicated test project, parallelization disabled assembly-wide, single-public-constructor constraints, MNTR log plumbing.
- OtOpcUa xunit constraint: the Akka TestKit family is xunit-v2-only; OtOpcUa's integration tree is xunit.v3.
- The one MNTR-only scenario (true boot-time partition → dual self-form) validates an already-documented accepted risk; the family's preferred proof is the docker rig drill (
failover-drill.shprecedent, ordocker network disconnect).
Follow-up: revisit alongside OtOpcUa per-cluster-mesh Phase 7 (failover-drill phase) if a scripted dual-island drill is ever wanted.
Out of scope (recorded follow-ups)
- Making OtOpcUa's 15 s
DowningStableAfterconfigurable — now listed in the OtOpcUa plan's "Reserved for additions". - Failure-detector tuning knobs for faster failover (both apps) — see
akka_failover.md§2 tuning table; OtOpcUa side listed in its reserved-additions section. - Staggered Windows service start delays (pure ops; document only).
deploy/wonder-app-vd03/gitignored production overlay (ops appliesSelfFormAfteron next deploy).- ScadaBridge site-pair failover from the central UI — optional Task 10 in the ScadaBridge plan (new central→site message contract; confirm with the user first).
Task C1: Family docs flip (execute AFTER both repo plans complete and merge)
Classification: trivial Estimated implement time: ~2 min
Files:
- Modify:
~/Desktop/scadaproj/akka_failover.md§6.1 — "Status: decided, not yet implemented" → implemented (merge commit refs from both repos); leave the §6.2DowningStableAfterline unless the OtOpcUa reserved-addition shipped. - Modify:
~/Desktop/scadaproj/CLAUDE.md— brief addition to the ScadaBridge and OtOpcUa index rows. - Update memory
ha-availability-over-partition-safety.md— mark the decision implemented.