fix(cluster): leader-auto-down restarts a node its peer has shut out; higher address restarts when both are (#132)
A node frozen past failure detection + StableAfter, or both sides of a partition that heals after each downed the other, used to stay a second active node until an operator restarted one: the peer quarantines the removed UID and Akka.Cluster 1.5.62 never acts on ThisActorSystemQuarantinedEvent. ShutOutRestart (child of the LeaderAutoDown downing actor; opt-out via ScadaBridge:Cluster:LeaderAutoDown:RestartWhenShutOut, default on for leader-auto-down only) watches two signals: - S1 ThisActorSystemQuarantinedEvent: the peer quarantined this node. If the peer is still a live member here, only this side can be shut out -> restart now. - S2 AssociationErrorEvent 'has a UID that has been quarantined': the peer still runs the incarnation this node removed. A healed partition yields only S2, on both sides. Wait ShutOutGrace (60s) for a frozen peer to restart itself, then tiebreak. Tiebreak: the higher address (Akka Member.AddressOrdering) restarts, the lower stays and restarts only if still split one grace later. Removed peers are probed (Identify, 5s -> 60s back-off) since nothing else contacts them after mutual removal. Restart = Cluster.Down(self) -> existing self-down path. The Host now exits 75 after an unplanned ActorSystem termination (and sets it on the Windows service before it reports stopped): SCM recovery with failureflag 1 only restarts a service that stops with a non-zero code, and the watchdog used to exit 0. Shipped default strategy stays auto-down. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -104,3 +104,36 @@ Do NOT adopt SBR keep-oldest/keep-majority for pairs: it re-introduces the prove
|
||||
- **Partition check:** confirm the accepted dual-active still occurs and recovers by restarting one side (documented operator path), and that
|
||||
both nodes then show `/health/active` 200 until recovery (monitoring contract unchanged).
|
||||
- **Gate for the bump PR:** new-strategy integration tests green, drill green, no deprecation warning in boot logs.
|
||||
|
||||
## 7. Addendum 2026-11-12: shut-out restart (ScadaBridge#132)
|
||||
|
||||
The #116 rig drill found a gap that `AutoDowning` and its port share: a node frozen past failure detection plus the window
|
||||
comes back as a **permanent second active node**, and the same happens to both sides of a partition that heals after each
|
||||
side downed the other. Root cause (Akka.NET 1.5.62): the peer's cluster quarantines the removed node's UID, Akka.Remote
|
||||
publishes `ThisActorSystemQuarantinedEvent` on the stale node, and Akka.Cluster never subscribes to it.
|
||||
|
||||
**Owner decision (#132):** under `leader-auto-down`, a node that is shut out restarts itself; when both sides are shut out,
|
||||
exactly one restarts, by a deterministic tiebreak. Implemented as `ShutOutRestart` (Host, a child of the downing actor, so
|
||||
`LeaderAutoDownBase` stays a line-for-line port), behind `ScadaBridge:Cluster:LeaderAutoDown:RestartWhenShutOut` (default
|
||||
`true` for this strategy only) and `ShutOutGrace` (default 60 s). The shipped default strategy stays `auto-down`.
|
||||
|
||||
- **Two signals, confirmed in-process.** S1 `ThisActorSystemQuarantinedEvent` (the frozen node). S2 an `AssociationErrorEvent`
|
||||
whose cause says "has a UID that has been quarantined" (the peer runs the incarnation this node removed). A healed
|
||||
partition produces **only S2, on both sides** — each side aborts the other's handshake with its own refused UID before a
|
||||
quarantine disassociate can arrive — so S1 alone would not have fixed the partition case. A removed peer is probed with
|
||||
`Identify` (5 s → 60 s back-off) because after mutual removal nothing else would ever contact it.
|
||||
- **Decision.** S1 while the peer is still a live member here: restart now (only this side can be shut out). S1 after this
|
||||
node removed the peer: tiebreak. S2: wait `ShutOutGrace` (a frozen peer restarts itself meanwhile), then tiebreak if still
|
||||
split. Tiebreak: **higher address restarts**, the lower stays and restarts only if the peer is still split from it one
|
||||
more grace later.
|
||||
- **Why address, not age:** see `Component-ClusterInfrastructure.md` → *Shut-out restart*. In short, both nodes hold both
|
||||
addresses always; the peer's up-number is history that can differ after the split.
|
||||
- **Exit code.** The restart is the existing self-down path, and it exposed a latent gap: the watchdog's `StopApplication()`
|
||||
exited 0, and Windows service recovery with `failureflag 1` acts only on a non-zero exit. The Host now exits 75 after an
|
||||
unplanned ActorSystem termination (also set on the Windows service before it reports stopped). Docker restarts on any code.
|
||||
- **Contract change for #116 gate 4:** the partition still produces dual-active *while it lasts*; after it heals, one side
|
||||
(the higher address) restarts and the pair converges with no operator action.
|
||||
`LeaderAutoDownScenarioTests.PartitionLongerThanWindow_EachSideDownsTheOther_BothStayUp` still holds (it never heals).
|
||||
- **Tests:** `ShutOutRestartTests` (unit), `ShutOutRestartScenarioTests` (two real nodes: freeze shape both address orders,
|
||||
healed partition, switch off), `UnexpectedTerminationTests`, `HoconBuilderTests`, `ClusterOptionsValidatorTests`.
|
||||
Rig evidence: `2026-11-12-leader-auto-down-rig-drill.md` §7.
|
||||
|
||||
Reference in New Issue
Block a user