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:
Joseph Doherty
2026-11-12 20:57:42 -05:00
parent a633005f4f
commit 952db02aab
14 changed files with 1646 additions and 10 deletions
@@ -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.