Cluster: guard against simultaneous-cold-start split-brain (both self-first seeds form separate 1-node clusters) #33
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?
Problem — simultaneous cold-start of both pair nodes splits the cluster
ScadaBridge runs 2-node Akka clusters where both nodes are self-first seeds. Confirmed in the shipped
configs, e.g.
docker/site-a-node-a/appsettings.Site.jsonvsdocker/site-a-node-b/appsettings.Site.json:Self-first-on-both is deliberate — it lets either node cold-start alone when its partner is dead
(only
seed-nodes[0] == selfruns Akka'sFirstSeedNodeProcess, the sole path that can form a newcluster). The cost: when both nodes cold-start at the same instant, each runs
FirstSeedNodeProcess,times out waiting for the other, and forms its own single-node cluster — a split brain (two oldest
nodes, two singletons, dual-active). Two independent clusters do not auto-merge, so it persists until
an operator restarts one side. This bites on any event that powers up both site VMs together (site power
restoration, hypervisor host reboot).
This is not theoretical: the sister project OtOpcUa (same
ZB.MOM.WW.*Akka topology, whose clustershape mirrors ScadaBridge's) reproduced it reliably on its docker rig — both nodes logged
JOINING itself … forming a new clusterand both advertised the primary role. In-process loopback testsusually converge (the
InitJoinhandshake resolves before the self-join deadline), which is why thishides until a real deployment starts both containers/VMs truly in parallel.
Fix — the bootstrap guard OtOpcUa built (please port)
OtOpcUa shipped an opt-in guard that eliminates the split without giving up cold-start-alone. Design:
Cluster:BootstrapGuard:Enabled) — off = today's config-driven self-firstauto-join, byte-identical. On = the node is given no config seed nodes (so Akka does not auto-join)
and a coordinator picks the join order after a reachability probe.
host:portis the preferred founder:self-first, forms immediately.
to
PartnerProbeSeconds: reachable ⇒ peer-first (join the founder, never race it);unreachable after the window ⇒ self-first (partner is genuinely down — cold-start-alone preserved
for the higher node too).
Cluster.JoinSeedNodes, from an explicit reachabilitysignal. It never re-forms mid-handshake — that was the failure mode of a prior timer-based
SelfFormAfterapproach (aCluster.Join(self)on a bare timeout is not ignored mid-handshake, itwins, and it islanded a node a failover had just bounced).
self-form (
JoinSeedNodeProcessretries forever). If the founder dies in the small probe→join window,the higher node hangs unjoined; the coordinator logs a WARNING after a bounded grace, and a restart
recovers it (the guard re-runs, finds the founder down, self-forms). We deliberately do not re-decide
mid-handshake.
Reference implementation (OtOpcUa)
d1dac87f(repodohertj2/lmxopcua).src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapGuard.cs— pure tie-break + reachability decision(fully unit-tested).
src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapCoordinator.cs—IHostedService: background probeJoinSeedNodes+ the operator-visible-hang warning.AkkaClusterOptionsgainedClusterBootstrapGuardOptions(Enabled,PartnerProbeSeconds[25],PartnerProbeIntervalMs[500],ProbeConnectTimeoutMs[1000]); validated fail-closed at boot.BuildClusterOptionsempties Akka's seed list when the guard is on; the coordinator is registered as ahosted service after
AddAkka.docs/Redundancy.md§"Bootstrap guard".Review notes worth carrying over (OtOpcUa code review caught these)
OrdinalIgnoreCaseonhost:port) — a hostnamecasing mismatch between the two nodes' configs would otherwise make both think they are the founder and
reopen the split.
PartnerProbeSeconds: 0typo silently degrades theguard to "never wait, always conclude the partner is dead."
the peer-first logic were wrong, and pure unit tests do not reproduce the true-simultaneous race.
Acceptance
one founder, one joiner, exactly one oldest/Primary, no split; and a node cold-starting alone (partner
down) still forms its cluster.
Notes
first). ScadaBridge's docker rig uses compose
depends_onfor this today, but that does not exist on thereal co-located VMs — the guard is the production-faithful fix.
pairs at once; implementing the guard in both keeps their failure/recovery model identical.