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:
@@ -111,7 +111,9 @@ Nothing in the emitted document enables remoting TLS or an Akka secure cookie
|
|||||||
|
|
||||||
### Downed-node recovery
|
### Downed-node recovery
|
||||||
|
|
||||||
`run-coordinated-shutdown-when-down = on` means a downed node runs `CoordinatedShutdown` and terminates its own `ActorSystem`. The Host watches `ActorSystem.WhenTerminated`; a termination that is not the host's own `StopAsync` calls `IHostApplicationLifetime.StopApplication()` so the process exits and the service supervisor (docker `restart: unless-stopped`, Windows service recovery) restarts it as a fresh incarnation (`AkkaHostedService.cs:203-218`).
|
`run-coordinated-shutdown-when-down = on` means a downed node runs `CoordinatedShutdown` and terminates its own `ActorSystem`. The Host watches `ActorSystem.WhenTerminated`; a termination that is not the host's own `StopAsync` calls `IHostApplicationLifetime.StopApplication()` so the process exits **with code 75** (`AkkaHostedService.SelfDownExitCode`; set by `Program` after the host stops, and on the Windows service itself before it reports `SERVICE_STOPPED`) and the service supervisor (docker `restart: unless-stopped`, Windows service recovery with `failureflag 1`, which only acts on a non-zero exit code) restarts it as a fresh incarnation (`AkkaHostedService.GetOrCreateActorSystem`).
|
||||||
|
|
||||||
|
Under `leader-auto-down` the same path restarts a node that its peer has **shut out** — downed, removed and quarantined it (a frozen node coming back), or both sides of a healed partition, where the higher address restarts. See `Component-ClusterInfrastructure.md` → *Shut-out restart* (ScadaBridge#132).
|
||||||
|
|
||||||
**Seed-node ordering (decision 2026-07-22).** Only the *first* seed listed in `Cluster:SeedNodes` may self-join to form a new cluster — Akka runs `FirstSeedNodeProcess` for it and `JoinSeedNodeProcess` (which can never form one) for everyone else. Every node therefore lists **itself** first and its partner second, so any node can boot alone and become operational unattended; `StartupValidator` fails the boot if that ordering is broken. Until this change all nodes shared one first seed, and a node that had to boot alone looped on `InitJoin` until its peer returned — the registered outage gap. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering for the scenario table and for why an external self-form timer was rejected.
|
**Seed-node ordering (decision 2026-07-22).** Only the *first* seed listed in `Cluster:SeedNodes` may self-join to form a new cluster — Akka runs `FirstSeedNodeProcess` for it and `JoinSeedNodeProcess` (which can never form one) for everyone else. Every node therefore lists **itself** first and its partner second, so any node can boot alone and become operational unattended; `StartupValidator` fails the boot if that ordering is broken. Until this change all nodes shared one first seed, and a node that had to boot alone looped on `InitJoin` until its peer returned — the registered outage gap. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering for the scenario table and for why an external self-form timer was rejected.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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).
|
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.
|
- **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.
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ Akka.NET cluster singletons run on the active node of their cluster and migrate
|
|||||||
|
|
||||||
Akka deprecated `AutoDowning` in 1.5.2 and will remove it in a future release. `SplitBrainResolverStrategy: leader-auto-down` selects `LeaderAutoDowningProvider` (`src/ZB.MOM.WW.ScadaBridge.Host/Actors/LeaderAutoDowningProvider.cs`), a line-for-line port of the 1.5.62 `AutoDowning`/`AutoDown`/`AutoDownBase` logic: identical leader-only, reachable-again and removed-member semantics, and the same `StableAfter` window. `BuildHocon` emits `downing-provider-class = "ZB.MOM.WW.ScadaBridge.Host.Actors.LeaderAutoDowningProvider, ZB.MOM.WW.ScadaBridge.Host"` and carries the window under `scadabridge.cluster.leader-auto-down.unreachable-after`, so no `auto-down-unreachable-after` key is emitted and Akka's boot-time deprecation warning is not logged. The default stays `auto-down` until the docker failover drill (`DRILL_MODE=active` and standby mode) passes on the new value; switching is an appsettings flip on both nodes of a pair together, and rollback is flipping back. Every downing decision is logged with a `[leader-auto-down]` prefix (unreachable observed and its window, skipped, cancelled on reachable/removed, kept pending on a non-leader, leadership changes, and `DOWNING` at Warning), so one node's log tells the whole story. Drill + rollout deploy gate: ScadaBridge#116. Plan: `docs/plans/2026-11-09-autodowning-replacement-plan.md`.
|
Akka deprecated `AutoDowning` in 1.5.2 and will remove it in a future release. `SplitBrainResolverStrategy: leader-auto-down` selects `LeaderAutoDowningProvider` (`src/ZB.MOM.WW.ScadaBridge.Host/Actors/LeaderAutoDowningProvider.cs`), a line-for-line port of the 1.5.62 `AutoDowning`/`AutoDown`/`AutoDownBase` logic: identical leader-only, reachable-again and removed-member semantics, and the same `StableAfter` window. `BuildHocon` emits `downing-provider-class = "ZB.MOM.WW.ScadaBridge.Host.Actors.LeaderAutoDowningProvider, ZB.MOM.WW.ScadaBridge.Host"` and carries the window under `scadabridge.cluster.leader-auto-down.unreachable-after`, so no `auto-down-unreachable-after` key is emitted and Akka's boot-time deprecation warning is not logged. The default stays `auto-down` until the docker failover drill (`DRILL_MODE=active` and standby mode) passes on the new value; switching is an appsettings flip on both nodes of a pair together, and rollback is flipping back. Every downing decision is logged with a `[leader-auto-down]` prefix (unreachable observed and its window, skipped, cancelled on reachable/removed, kept pending on a non-leader, leadership changes, and `DOWNING` at Warning), so one node's log tells the whole story. Drill + rollout deploy gate: ScadaBridge#116. Plan: `docs/plans/2026-11-09-autodowning-replacement-plan.md`.
|
||||||
|
|
||||||
|
#### Shut-out restart (`leader-auto-down` only, ScadaBridge#132)
|
||||||
|
|
||||||
|
Without it, a node that was **frozen** (VM pause, long GC stall, `docker pause`) for longer than failure detection plus `StableAfter` comes back as a **second, permanent active node**: its peer has already downed and removed it, quarantining its UID, and Akka.Cluster 1.5.62 never tells the frozen node — so it downs the peer in its own one-node view. Both sides of a **real partition that heals** after each side downed the other end the same way. This happens under `auto-down` too (Akka core behaviour); the in-repo provider is where it is fixed.
|
||||||
|
|
||||||
|
With `ScadaBridge:Cluster:LeaderAutoDown:RestartWhenShutOut` on (**the default whenever `leader-auto-down` is selected**), the `ShutOutRestart` actor (`src/ZB.MOM.WW.ScadaBridge.Host/Actors/ShutOutRestart.cs`, a child of the downing actor) restarts the stale side:
|
||||||
|
|
||||||
|
| Signal (Akka.NET 1.5.62) | Who sees it | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| **S1** `ThisActorSystemQuarantinedEvent` — the peer has quarantined *this* node — while the peer is still a live member in this node's view | the frozen node | **restart now**; no tiebreak (this node has not removed the peer, so the peer cannot be shut out by it) |
|
||||||
|
| **S1** after this node has removed the peer too | either side of a split | **tiebreak now** |
|
||||||
|
| **S2** `AssociationErrorEvent` "remote system has a UID that has been quarantined" — the peer still runs the incarnation *this* node removed | both sides of a healed partition; the survivor of a freeze | wait `ShutOutGrace` (a frozen peer restarts itself on S1 meanwhile); still split at the end ⇒ **tiebreak** |
|
||||||
|
|
||||||
|
**Tiebreak: the higher address (host, then port — Akka's `Member.AddressOrdering`) restarts; the lower stays.** The addresses are in every signal and in both nodes' seed lists, so both nodes compute the same answer without talking. Member age (up-number) was rejected: by the time a signal fires both sides have removed each other, so the peer's up-number exists only as remembered history that can differ (an unconverged join, a restart since), and a disagreement means both restart or neither. The lower address is also the Akka leader of the re-formed pair and the bootstrap guard's preferred founder. The lower node gives its peer one more `ShutOutGrace` and restarts only if the peer has neither rejoined nor gone quiet by then (the single-sided case where the stale side happened to be the lower address).
|
||||||
|
|
||||||
|
After both sides have removed each other neither sends the other anything, so a healed partition would stay silent: the actor **probes** (an `Identify` to `/system/cluster/core/daemon`) every peer it saw removed after a Down, backing off 5 s → 60 s, until that address is a member again. Each probe of a dead peer logs one Akka association warning.
|
||||||
|
|
||||||
|
"Restart" is the existing self-down path (below): `Cluster.Down(self)` ⇒ the `ActorSystem` terminates ⇒ the process exits **75** ⇒ the supervisor restarts it and it rejoins as standby. Every decision logs a `[leader-auto-down]` line: `shut out by <peer> … tiebreak not needed — this node RESTARTS`, `split from <peer> … Giving it <grace> …`, `Tiebreak says RESTART|STAY`, `rejoined … stays`. Settings: `RestartWhenShutOut` (bool, default `true`) and `ShutOutGrace` (default `60s`, must be positive, must cover the peer's worst-case shutdown). Off restores the pre-#132 behaviour (dual-active until an operator restarts one side). Proven by `ShutOutRestartTests` (unit) and `ShutOutRestartScenarioTests` (real two-node clusters, freeze shape and healed partition); rig evidence in `docs/plans/2026-11-12-leader-auto-down-rig-drill.md` §7.
|
||||||
|
|
||||||
### Why not the alternatives (all verified against Akka.NET 1.5.62 source, 2026-07-21)
|
### Why not the alternatives (all verified against Akka.NET 1.5.62 source, 2026-07-21)
|
||||||
|
|
||||||
- **keep-oldest** (used until 2026-07-21): partition-safe, but in a two-node cluster a crash of the **oldest** is a total outage. `KeepOldest.OldestDecision` only lets `down-if-alone` rescue the survivor when the surviving side has **≥ 2 members** (`otherSide == 1 && thisSide >= 2`); with 1-vs-1 the younger survivor takes `DownReachable` — it downs *itself*. Verified live on the docker rig: the survivor logged `SBR took decision Akka.Cluster.SBR.DownReachable … including myself`, exited, and looped on `InitJoin`. The strategy remains supported in `ClusterOptions` (`SplitBrainResolverStrategy: keep-oldest`) for deployments that prefer partition-safety over availability.
|
- **keep-oldest** (used until 2026-07-21): partition-safe, but in a two-node cluster a crash of the **oldest** is a total outage. `KeepOldest.OldestDecision` only lets `down-if-alone` rescue the survivor when the surviving side has **≥ 2 members** (`otherSide == 1 && thisSide >= 2`); with 1-vs-1 the younger survivor takes `DownReachable` — it downs *itself*. Verified live on the docker rig: the survivor logged `SBR took decision Akka.Cluster.SBR.DownReachable … including myself`, exited, and looped on `InitJoin`. The strategy remains supported in `ClusterOptions` (`SplitBrainResolverStrategy: keep-oldest`) for deployments that prefer partition-safety over availability.
|
||||||
@@ -116,8 +134,8 @@ Akka deprecated `AutoDowning` in 1.5.2 and will remove it in a future release. `
|
|||||||
When a node is downed (auto-downed by its peer after a partition heals, or a keep-oldest self-down where that strategy is configured), `run-coordinated-shutdown-when-down = on` runs `CoordinatedShutdown` and **terminates that node's `ActorSystem`**. The Host process must not keep running with a dead actor system — it would serve nothing and be restarted by nobody. The recovery contract is:
|
When a node is downed (auto-downed by its peer after a partition heals, or a keep-oldest self-down where that strategy is configured), `run-coordinated-shutdown-when-down = on` runs `CoordinatedShutdown` and **terminates that node's `ActorSystem`**. The Host process must not keep running with a dead actor system — it would serve nothing and be restarted by nobody. The recovery contract is:
|
||||||
|
|
||||||
1. Down ⇒ `CoordinatedShutdown` ⇒ `ActorSystem` termination.
|
1. Down ⇒ `CoordinatedShutdown` ⇒ `ActorSystem` termination.
|
||||||
2. The Host watches `ActorSystem.WhenTerminated`; a termination that is **not** the host's own `StopAsync` triggers `IHostApplicationLifetime.StopApplication()`, so **the process exits**.
|
2. The Host watches `ActorSystem.WhenTerminated`; a termination that is **not** the host's own `StopAsync` triggers `IHostApplicationLifetime.StopApplication()`, so **the process exits — with code 75** (`AkkaHostedService.SelfDownExitCode`, `EX_TEMPFAIL`; ScadaBridge#132). Under the Windows service lifetime the code is also set on the service (`ServiceBase.ExitCode`) before the stop, so the SCM sees it.
|
||||||
3. The service supervisor restarts it — docker `restart: unless-stopped`, or Windows service recovery actions (`sc.exe failure … restart/…`).
|
3. The service supervisor restarts it — docker `restart: unless-stopped` (any exit code), or Windows service recovery actions (`sc.exe failure … restart/…` **plus `sc.exe failureflag <svc> 1`**, both set by the wonder install script `deploy/wonder-app-vd03/install.ps1`, which is kept outside git). With `failureflag 1` the SCM runs the recovery actions for a service that stops with a **non-zero** exit code; before #132 the watchdog exit reported 0, which the SCM treats as a clean stop. (ScadaBridge runs as native .NET Windows services; NSSM wraps only the LDAP directory.)
|
||||||
4. The restarted process rejoins as a **fresh incarnation**, and can re-form the cluster on its own if no peer is reachable — see Seed Node Ordering below.
|
4. The restarted process rejoins as a **fresh incarnation**, and can re-form the cluster on its own if no peer is reachable — see Seed Node Ordering below.
|
||||||
|
|
||||||
### Seed Node Ordering
|
### Seed Node Ordering
|
||||||
|
|||||||
@@ -144,6 +144,61 @@ public class ClusterOptions
|
|||||||
/// <c>docs/plans/2026-11-09-tls-production-profile-design.md</c>.
|
/// <c>docs/plans/2026-11-09-tls-production-profile-design.md</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ClusterRemotingTlsOptions RemotingTls { get; set; } = new();
|
public ClusterRemotingTlsOptions RemotingTls { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings that apply only when <see cref="SplitBrainResolverStrategy"/> is
|
||||||
|
/// <c>leader-auto-down</c> (<c>ScadaBridge:Cluster:LeaderAutoDown</c>, ScadaBridge#132).
|
||||||
|
/// Inert under every other strategy: <c>AkkaHostedService.BuildHocon</c> emits none of it.
|
||||||
|
/// </summary>
|
||||||
|
public LeaderAutoDownOptions LeaderAutoDown { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>leader-auto-down</c>-only settings (ScadaBridge#132): what a node does when it learns that
|
||||||
|
/// its pair peer has <b>shut it out</b> — removed and quarantined it, so it can never talk to that
|
||||||
|
/// peer again until it restarts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why it exists.</b> A node that was frozen (VM pause, GC stall, <c>docker pause</c>) past the
|
||||||
|
/// failure-detection window plus <see cref="ClusterOptions.StableAfter"/> is downed and removed by
|
||||||
|
/// its peer. When it resumes, every association is refused with "the remote system has quarantined
|
||||||
|
/// this system" — Akka.Remote publishes <c>ThisActorSystemQuarantinedEvent</c>, but Akka.Cluster
|
||||||
|
/// 1.5.62 never acts on it, so the frozen node downs the peer in its own one-node view and becomes
|
||||||
|
/// a second, permanent active node. The same thing happens to <b>both</b> nodes after a real
|
||||||
|
/// network partition heals.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What it does</b> (with <see cref="RestartWhenShutOut"/> on): a node that is shut out by its
|
||||||
|
/// peer while it still counts that peer as a member restarts itself (it is the stale side). When it
|
||||||
|
/// has already removed the peer too — both sides shut each other out — a deterministic tiebreak
|
||||||
|
/// both nodes compute identically picks ONE side: the node with the <b>higher</b> address
|
||||||
|
/// (host, then port; Akka's own member address ordering) restarts, the lower stays. The lower node
|
||||||
|
/// restarts too only if it is still being shut out after <see cref="ShutOutGrace"/>. "Restart" is
|
||||||
|
/// the existing self-down path: <c>Cluster.Down(self)</c>, the <c>ActorSystem</c> terminates, the
|
||||||
|
/// Host exits non-zero and the service supervisor restarts it as a fresh incarnation that rejoins
|
||||||
|
/// as standby. Implemented by <c>ShutOutRestart</c> in the Host.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LeaderAutoDownOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a node shut out by its pair peer restarts itself (see the class remarks). Default
|
||||||
|
/// <see langword="true"/> — on whenever <c>leader-auto-down</c> is selected. Set
|
||||||
|
/// <see langword="false"/> to get the old behaviour back: a frozen node or a healed partition
|
||||||
|
/// leaves two active nodes until an operator restarts one.
|
||||||
|
/// </summary>
|
||||||
|
public bool RestartWhenShutOut { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the tiebreak WINNER (the lower-address node) waits for its peer to restart before
|
||||||
|
/// it re-checks. At the end of the grace it restarts only if the peer is still not a member AND
|
||||||
|
/// it was shut out again in the last few seconds — i.e. the peer never restarted, so this node
|
||||||
|
/// is the stale side after all. Must cover the peer's worst-case shut-down time (its old
|
||||||
|
/// incarnation keeps refusing until its <c>ActorSystem</c> has terminated). Default 60s.
|
||||||
|
/// Validated <c>> 0</c> at boot when the feature is on.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan ShutOutGrace { get; set; } = TimeSpan.FromSeconds(60);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
|||||||
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
|
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
|
||||||
"declared unreachable before a heartbeat can arrive.");
|
"declared unreachable before a heartbeat can arrive.");
|
||||||
|
|
||||||
|
// ScadaBridge#132: the shut-out restart grace is only consulted under leader-auto-down
|
||||||
|
// with the feature on; a zero/negative grace would make the tiebreak winner restart on
|
||||||
|
// the spot, i.e. BOTH nodes of a healed partition restart together.
|
||||||
|
var isLeaderAutoDown = string.Equals(
|
||||||
|
options.SplitBrainResolverStrategy, "leader-auto-down", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var leaderAutoDown = options.LeaderAutoDown ?? new LeaderAutoDownOptions();
|
||||||
|
builder.RequireThat(
|
||||||
|
!isLeaderAutoDown || !leaderAutoDown.RestartWhenShutOut || leaderAutoDown.ShutOutGrace > TimeSpan.Zero,
|
||||||
|
"ClusterOptions.LeaderAutoDown.ShutOutGrace must be a positive duration when "
|
||||||
|
+ "LeaderAutoDown.RestartWhenShutOut is on (ScadaBridge#132).");
|
||||||
|
|
||||||
// DownIfAlone is a keep-oldest knob; under (leader-)auto-down each side downs the
|
// DownIfAlone is a keep-oldest knob; under (leader-)auto-down each side downs the
|
||||||
// unreachable peer regardless, so the flag is inert and any value is fine.
|
// unreachable peer regardless, so the flag is inert and any value is fine.
|
||||||
var isKeepOldest = string.Equals(
|
var isKeepOldest = string.Equals(
|
||||||
|
|||||||
@@ -34,8 +34,20 @@ public class AkkaHostedService : IHostedService
|
|||||||
private readonly CommunicationOptions _communicationOptions;
|
private readonly CommunicationOptions _communicationOptions;
|
||||||
private readonly ILogger<AkkaHostedService> _logger;
|
private readonly ILogger<AkkaHostedService> _logger;
|
||||||
private readonly IHostApplicationLifetime? _appLifetime;
|
private readonly IHostApplicationLifetime? _appLifetime;
|
||||||
|
private readonly IHostLifetime? _hostLifetime;
|
||||||
private ActorSystem? _actorSystem;
|
private ActorSystem? _actorSystem;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process exit code after an ActorSystem termination outside host shutdown (a self-down,
|
||||||
|
/// or a ScadaBridge#132 shut-out restart): 75, <c>EX_TEMPFAIL</c> — "temporary failure, try
|
||||||
|
/// again". It must be NON-zero: docker <c>restart: unless-stopped</c> restarts on any exit, but
|
||||||
|
/// Windows service recovery (<c>sc.exe failure … restart</c> with <c>failureflag 1</c>, set by
|
||||||
|
/// <c>deploy/wonder-app-vd03/install.ps1</c>) applies its restart actions to a service that
|
||||||
|
/// reports <c>SERVICE_STOPPED</c> only when the reported exit code is not 0. A clean
|
||||||
|
/// <c>StopApplication()</c> alone reports 0, so the node would stay stopped.
|
||||||
|
/// </summary>
|
||||||
|
public const int SelfDownExitCode = 75;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flips to <c>true</c> the moment <see cref="StopAsync"/> begins, so the
|
/// Flips to <c>true</c> the moment <see cref="StopAsync"/> begins, so the
|
||||||
/// <see cref="ActorSystem.WhenTerminated"/> watchdog can tell an
|
/// <see cref="ActorSystem.WhenTerminated"/> watchdog can tell an
|
||||||
@@ -84,14 +96,17 @@ public class AkkaHostedService : IHostedService
|
|||||||
/// <param name="communicationOptions">The communication configuration options.</param>
|
/// <param name="communicationOptions">The communication configuration options.</param>
|
||||||
/// <param name="logger">The logger instance.</param>
|
/// <param name="logger">The logger instance.</param>
|
||||||
/// <param name="appLifetime">The host application lifetime; optional so existing construction sites (tests, DI variants) keep working without it.</param>
|
/// <param name="appLifetime">The host application lifetime; optional so existing construction sites (tests, DI variants) keep working without it.</param>
|
||||||
|
/// <param name="hostLifetime">The host lifetime; when it is the Windows service lifetime, the self-down exit code is reported to the SCM through it. Optional.</param>
|
||||||
public AkkaHostedService(
|
public AkkaHostedService(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IOptions<NodeOptions> nodeOptions,
|
IOptions<NodeOptions> nodeOptions,
|
||||||
IOptions<ClusterOptions> clusterOptions,
|
IOptions<ClusterOptions> clusterOptions,
|
||||||
IOptions<CommunicationOptions> communicationOptions,
|
IOptions<CommunicationOptions> communicationOptions,
|
||||||
ILogger<AkkaHostedService> logger,
|
ILogger<AkkaHostedService> logger,
|
||||||
IHostApplicationLifetime? appLifetime = null)
|
IHostApplicationLifetime? appLifetime = null,
|
||||||
|
IHostLifetime? hostLifetime = null)
|
||||||
{
|
{
|
||||||
|
_hostLifetime = hostLifetime;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_nodeOptions = nodeOptions.Value;
|
_nodeOptions = nodeOptions.Value;
|
||||||
_clusterOptions = clusterOptions.Value;
|
_clusterOptions = clusterOptions.Value;
|
||||||
@@ -212,8 +227,11 @@ public class AkkaHostedService : IHostedService
|
|||||||
{
|
{
|
||||||
if (_stopRequested) return;
|
if (_stopRequested) return;
|
||||||
_logger.LogCritical(
|
_logger.LogCritical(
|
||||||
"ActorSystem terminated outside host shutdown (SBR down / run-coordinated-shutdown-when-down). "
|
"ActorSystem terminated outside host shutdown (SBR down / run-coordinated-shutdown-when-down / "
|
||||||
+ "Stopping the host process so the service supervisor restarts this node as a fresh incarnation.");
|
+ "leader-auto-down shut-out restart). Stopping the host process with exit code {ExitCode} so the "
|
||||||
|
+ "service supervisor restarts this node as a fresh incarnation.", SelfDownExitCode);
|
||||||
|
_terminatedOutsideHostShutdown = true;
|
||||||
|
MarkServiceSelfDownExit(_hostLifetime);
|
||||||
_appLifetime?.StopApplication();
|
_appLifetime?.StopApplication();
|
||||||
}, TaskContinuationOptions.ExecuteSynchronously);
|
}, TaskContinuationOptions.ExecuteSynchronously);
|
||||||
|
|
||||||
@@ -223,6 +241,37 @@ public class AkkaHostedService : IHostedService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see langword="true"/> once the <see cref="ActorSystem"/> has terminated outside
|
||||||
|
/// <see cref="StopAsync"/> (a self-down or a shut-out restart). <c>Program</c> reads it after
|
||||||
|
/// <c>RunAsync</c> returns and exits with <see cref="SelfDownExitCode"/>; it is a flag rather than a
|
||||||
|
/// direct write of the process exit code so test hosts that terminate a system never inherit
|
||||||
|
/// a non-zero process exit code.
|
||||||
|
/// </summary>
|
||||||
|
public bool TerminatedOutsideHostShutdown => _terminatedOutsideHostShutdown;
|
||||||
|
|
||||||
|
private volatile bool _terminatedOutsideHostShutdown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the host runs as a Windows service, sets <see cref="SelfDownExitCode"/> as the exit code
|
||||||
|
/// the service reports to the SCM when it stops — the value Windows service recovery
|
||||||
|
/// (<c>failureflag 1</c>) keys its restart on. Must run BEFORE <c>StopApplication()</c>: the
|
||||||
|
/// Windows service lifetime reports <c>SERVICE_STOPPED</c> during host shutdown, before
|
||||||
|
/// <c>Program</c> can set the process exit code.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hostLifetime">The host lifetime; the Windows service lifetime is a <c>ServiceBase</c>.</param>
|
||||||
|
/// <returns><see langword="true"/> when the exit code was set on a Windows service lifetime.</returns>
|
||||||
|
internal static bool MarkServiceSelfDownExit(IHostLifetime? hostLifetime)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows() && hostLifetime is System.ServiceProcess.ServiceBase service)
|
||||||
|
{
|
||||||
|
service.ExitCode = SelfDownExitCode;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the Akka HOCON configuration document. Every interpolated value is
|
/// Builds the Akka HOCON configuration document. Every interpolated value is
|
||||||
/// routed through <see cref="QuoteHocon"/> (string values) so a hostname,
|
/// routed through <see cref="QuoteHocon"/> (string values) so a hostname,
|
||||||
@@ -313,8 +362,12 @@ public class AkkaHostedService : IHostedService
|
|||||||
// The leader-auto-down duration sits outside akka.cluster so Akka never mistakes it
|
// The leader-auto-down duration sits outside akka.cluster so Akka never mistakes it
|
||||||
// for one of its own (deprecated) auto-down keys. Only emitted for that strategy, so
|
// for one of its own (deprecated) auto-down keys. Only emitted for that strategy, so
|
||||||
// the auto-down / keep-oldest documents are byte-identical to before.
|
// the auto-down / keep-oldest documents are byte-identical to before.
|
||||||
|
// ScadaBridge#132: the shut-out restart switch and grace ride in the same block.
|
||||||
|
var shutOut = clusterOptions.LeaderAutoDown ?? new LeaderAutoDownOptions();
|
||||||
var leaderAutoDownBlock = string.Equals(strategy, "leader-auto-down", StringComparison.OrdinalIgnoreCase)
|
var leaderAutoDownBlock = string.Equals(strategy, "leader-auto-down", StringComparison.OrdinalIgnoreCase)
|
||||||
? $"{LeaderAutoDowningProvider.UnreachableAfterPath} = {DurationHocon(clusterOptions.StableAfter)}\n"
|
? $"{LeaderAutoDowningProvider.UnreachableAfterPath} = {DurationHocon(clusterOptions.StableAfter)}\n"
|
||||||
|
+ $"{ShutOutRestartSettings.EnabledPath} = {(shutOut.RestartWhenShutOut ? "on" : "off")}\n"
|
||||||
|
+ $"{ShutOutRestartSettings.GracePath} = {DurationHocon(shutOut.ShutOutGrace)}\n"
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
// Akka.Remote TLS (ScadaBridge#64, opt-in). Emitted inside dot-netty.tcp only when
|
// Akka.Remote TLS (ScadaBridge#64, opt-in). Emitted inside dot-netty.tcp only when
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public sealed class LeaderAutoDowningProvider : IDowningProvider
|
|||||||
$"LeaderAutoDowningProvider selected but '{UnreachableAfterPath}' not set");
|
$"LeaderAutoDowningProvider selected but '{UnreachableAfterPath}' not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
return LeaderAutoDown.Props(after.Value, _cluster);
|
return LeaderAutoDown.Props(
|
||||||
|
after.Value, _cluster, ShutOutRestartSettings.FromConfig(_system.Settings.Config));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +119,29 @@ public sealed class LeaderAutoDowningProvider : IDowningProvider
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LeaderAutoDown : LeaderAutoDownBase
|
internal sealed class LeaderAutoDown : LeaderAutoDownBase
|
||||||
{
|
{
|
||||||
|
/// <summary>Child name of the <see cref="ShutOutRestart"/> actor.</summary>
|
||||||
|
internal const string ShutOutRestartName = "shut-out-restart";
|
||||||
|
|
||||||
private readonly Akka.Cluster.Cluster _cluster;
|
private readonly Akka.Cluster.Cluster _cluster;
|
||||||
|
private readonly ShutOutRestartSettings? _shutOut;
|
||||||
|
|
||||||
/// <summary>Props for the downing actor.</summary>
|
/// <summary>Props for the downing actor.</summary>
|
||||||
/// <param name="unreachableAfter">How long a member must stay unreachable before it is downed.</param>
|
/// <param name="unreachableAfter">How long a member must stay unreachable before it is downed.</param>
|
||||||
/// <param name="cluster">The cluster extension.</param>
|
/// <param name="cluster">The cluster extension.</param>
|
||||||
public static Props Props(TimeSpan unreachableAfter, Akka.Cluster.Cluster cluster) =>
|
/// <param name="shutOut">Shut-out restart settings (ScadaBridge#132); null or disabled = no shut-out handling.</param>
|
||||||
Akka.Actor.Props.Create(() => new LeaderAutoDown(unreachableAfter, cluster));
|
/// <returns>The props.</returns>
|
||||||
|
public static Props Props(TimeSpan unreachableAfter, Akka.Cluster.Cluster cluster, ShutOutRestartSettings? shutOut = null) =>
|
||||||
|
Akka.Actor.Props.Create(() => new LeaderAutoDown(unreachableAfter, cluster, shutOut));
|
||||||
|
|
||||||
/// <summary>Creates the actor.</summary>
|
/// <summary>Creates the actor.</summary>
|
||||||
/// <param name="unreachableAfter">How long a member must stay unreachable before it is downed.</param>
|
/// <param name="unreachableAfter">How long a member must stay unreachable before it is downed.</param>
|
||||||
/// <param name="cluster">The cluster extension.</param>
|
/// <param name="cluster">The cluster extension.</param>
|
||||||
public LeaderAutoDown(TimeSpan unreachableAfter, Akka.Cluster.Cluster cluster)
|
/// <param name="shutOut">Shut-out restart settings (ScadaBridge#132); null or disabled = no shut-out handling.</param>
|
||||||
|
public LeaderAutoDown(TimeSpan unreachableAfter, Akka.Cluster.Cluster cluster, ShutOutRestartSettings? shutOut)
|
||||||
: base(unreachableAfter)
|
: base(unreachableAfter)
|
||||||
{
|
{
|
||||||
_cluster = cluster;
|
_cluster = cluster;
|
||||||
|
_shutOut = shutOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -145,6 +154,22 @@ internal sealed class LeaderAutoDown : LeaderAutoDownBase
|
|||||||
protected override void PreStart()
|
protected override void PreStart()
|
||||||
{
|
{
|
||||||
_cluster.Subscribe(Self, typeof(ClusterEvent.IClusterDomainEvent));
|
_cluster.Subscribe(Self, typeof(ClusterEvent.IClusterDomainEvent));
|
||||||
|
|
||||||
|
// ScadaBridge#132: the shut-out restart is a separate child so the downing logic above
|
||||||
|
// stays a line-for-line port of Akka's AutoDownBase. A child, not a sibling, so it lives
|
||||||
|
// and dies with this provider's actor and exists only under leader-auto-down.
|
||||||
|
if (_shutOut is { Enabled: true })
|
||||||
|
{
|
||||||
|
Context.ActorOf(ShutOutRestart.Props(_shutOut, _cluster), ShutOutRestartName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Context.GetLogger().Info(
|
||||||
|
"[leader-auto-down] shut-out restart is OFF for {0} (ScadaBridge:Cluster:LeaderAutoDown:RestartWhenShutOut = false): "
|
||||||
|
+ "a node its peer has removed and quarantined keeps running as a second active node until it is restarted",
|
||||||
|
_cluster.SelfAddress);
|
||||||
|
}
|
||||||
|
|
||||||
base.PreStart();
|
base.PreStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using Akka.Event;
|
||||||
|
using Akka.Remote;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for <see cref="ShutOutRestart"/>, read from the <c>scadabridge.cluster.leader-auto-down</c>
|
||||||
|
/// HOCON block that <see cref="AkkaHostedService.BuildHocon"/> emits from
|
||||||
|
/// <c>ScadaBridge:Cluster:LeaderAutoDown</c> (ScadaBridge#132).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Enabled">Whether a node that is shut out by its peer restarts itself.</param>
|
||||||
|
/// <param name="Grace">How long a peer gets to restart itself before this node acts (see <see cref="ShutOutRestart"/>).</param>
|
||||||
|
/// <param name="ProbeInterval">First interval between probes of a removed peer, and the fixed interval while a grace runs.</param>
|
||||||
|
/// <param name="ProbeMaxInterval">Cap for the probe back-off while no grace runs.</param>
|
||||||
|
internal sealed record ShutOutRestartSettings(
|
||||||
|
bool Enabled, TimeSpan Grace, TimeSpan ProbeInterval, TimeSpan ProbeMaxInterval)
|
||||||
|
{
|
||||||
|
/// <summary>HOCON switch: <c>on</c>/<c>off</c>. Missing = on.</summary>
|
||||||
|
public const string EnabledPath = "scadabridge.cluster.leader-auto-down.restart-when-shut-out";
|
||||||
|
|
||||||
|
/// <summary>HOCON duration for <see cref="Grace"/>. Missing = 60s.</summary>
|
||||||
|
public const string GracePath = "scadabridge.cluster.leader-auto-down.shut-out-grace";
|
||||||
|
|
||||||
|
/// <summary>HOCON duration for <see cref="ProbeInterval"/>. Missing = 5s. Not bound from appsettings (tests only).</summary>
|
||||||
|
public const string ProbeIntervalPath = "scadabridge.cluster.leader-auto-down.shut-out-probe-interval";
|
||||||
|
|
||||||
|
/// <summary>HOCON duration for <see cref="ProbeMaxInterval"/>. Missing = 60s. Not bound from appsettings (tests only).</summary>
|
||||||
|
public const string ProbeMaxIntervalPath = "scadabridge.cluster.leader-auto-down.shut-out-probe-max-interval";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How recent the last shut-out evidence must be, when a grace ends, for the split to count as
|
||||||
|
/// still in place: three probe intervals, so a missed probe or two does not flip the verdict.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan RecentWindow => ProbeInterval * 3;
|
||||||
|
|
||||||
|
/// <summary>Reads the settings, defaulting every missing key.</summary>
|
||||||
|
/// <param name="config">The actor system's configuration.</param>
|
||||||
|
/// <returns>The settings.</returns>
|
||||||
|
public static ShutOutRestartSettings FromConfig(Config config)
|
||||||
|
{
|
||||||
|
var enabled = !config.HasPath(EnabledPath) || config.GetBoolean(EnabledPath, true);
|
||||||
|
return new ShutOutRestartSettings(
|
||||||
|
enabled,
|
||||||
|
Duration(config, GracePath, TimeSpan.FromSeconds(60)),
|
||||||
|
Duration(config, ProbeIntervalPath, TimeSpan.FromSeconds(5)),
|
||||||
|
Duration(config, ProbeMaxIntervalPath, TimeSpan.FromSeconds(60)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan Duration(Config config, string path, TimeSpan fallback) =>
|
||||||
|
config.HasPath(path) ? config.GetTimeSpan(path, fallback, true) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132: makes a node that is split from its pair peer — the peer has downed and removed
|
||||||
|
/// it, or each has removed the other — restart itself, so a frozen node or a healed partition does
|
||||||
|
/// not leave two active nodes until an operator acts. Runs as a child of the
|
||||||
|
/// <see cref="LeaderAutoDown"/> downing actor, so it exists only under <c>leader-auto-down</c>, and
|
||||||
|
/// only when <c>ScadaBridge:Cluster:LeaderAutoDown:RestartWhenShutOut</c> is on (the default for
|
||||||
|
/// that strategy).
|
||||||
|
/// <para>
|
||||||
|
/// <b>The signals</b> (Akka.NET 1.5.62, read from the decompiled assemblies and confirmed by the
|
||||||
|
/// in-process scenario tests). When a node removes a downed member, its cluster quarantines that
|
||||||
|
/// member's UID. After that, each association between the two incarnations fails, and which side
|
||||||
|
/// learns what depends on who still counts whom as a member:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>S1 — "the peer has quarantined THIS node".</b> Akka.Remote publishes
|
||||||
|
/// <see cref="ThisActorSystemQuarantinedEvent"/> (<c>EndpointManager</c>, on an
|
||||||
|
/// <c>InvalidAssociation</c> whose disassociate reason is <c>Quarantined</c>). Seen by a node that
|
||||||
|
/// still talks to a peer which has removed it: the frozen node after <c>docker pause</c>. Akka.Cluster
|
||||||
|
/// 1.5.62 never subscribes to it — <c>ClusterCoreDaemon</c> handles only the opposite
|
||||||
|
/// <c>QuarantinedEvent</c> — which is the root cause of #132.</item>
|
||||||
|
/// <item><b>S2 — "the peer is still running the incarnation THIS node removed".</b> An outbound
|
||||||
|
/// handshake that meets a UID this node has quarantined is aborted (<c>ProtocolStateActor</c>,
|
||||||
|
/// <c>ForbiddenUidReason</c>: "The remote system has a UID that has been quarantined"), surfacing as
|
||||||
|
/// an <see cref="AssociationErrorEvent"/>. Seen by both sides of a partition that healed after each
|
||||||
|
/// side downed the other — neither of them sees S1, because each aborts the other's handshake first —
|
||||||
|
/// and by the survivor of a freeze when it probes the frozen node.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The decision.</b>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><b>S1 while the peer is still a live member in this node's view</b> (Joining, WeaklyUp, Up
|
||||||
|
/// or Leaving): this node has not removed the peer, so it has not quarantined it, so the peer
|
||||||
|
/// cannot be shut out by this node — only this side is. This is the frozen node. <b>Restart
|
||||||
|
/// now</b>; no tiebreak is needed.</item>
|
||||||
|
/// <item><b>S1 after this node has removed the peer too</b>: both sides are shut out.
|
||||||
|
/// <b>Tiebreak now</b> (below).</item>
|
||||||
|
/// <item><b>S2</b>: the peer's old incarnation is alive. It may be a frozen node about to restart
|
||||||
|
/// itself on S1 — so this node first gives it <see cref="ShutOutRestartSettings.Grace"/>. If, at
|
||||||
|
/// the end, the peer has neither rejoined nor gone quiet, both sides are shut out: <b>tiebreak</b>.
|
||||||
|
/// </item>
|
||||||
|
/// <item><b>Tiebreak</b>: the node with the <b>higher</b> address restarts now. The lower one stays
|
||||||
|
/// and gives the peer one more <see cref="ShutOutRestartSettings.Grace"/>; it restarts only if the
|
||||||
|
/// peer has still not rejoined and is still producing evidence at the end — the single-sided case
|
||||||
|
/// where the stale side happened to be the lower address.</item>
|
||||||
|
/// </list>
|
||||||
|
/// "Quiet" means no S1/S2 from that peer for <see cref="ShutOutRestartSettings.RecentWindow"/>: the
|
||||||
|
/// old incarnation is gone (restarting, or dead), so there is nothing left to split from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why the address tiebreak, not member age.</b> Both nodes must reach the same answer without
|
||||||
|
/// talking to each other, from state they already hold. The two addresses are in every signal and
|
||||||
|
/// in both nodes' seed lists, so the comparison is identical on both sides by construction. Age
|
||||||
|
/// (up-number) is not: by the time a signal fires both sides have removed each other, so the peer's
|
||||||
|
/// up-number survives only as remembered history, which differs if a join never converged before
|
||||||
|
/// the split or either side has restarted since. A disagreement there means both restart or neither
|
||||||
|
/// does — exactly what the tiebreak exists to prevent. The ordering is Akka's own
|
||||||
|
/// <see cref="Member.AddressOrdering"/> (host, then port), which also picks the Akka leader of the
|
||||||
|
/// re-formed pair, and matches the bootstrap guard, where the lower address is the preferred founder.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Probing.</b> After both sides have removed each other neither sends the other anything, so a
|
||||||
|
/// healed partition would stay silent and neither signal would ever fire. This actor therefore
|
||||||
|
/// sends an <see cref="Identify"/> to every peer it saw removed after being downed, with a back-off
|
||||||
|
/// from <see cref="ShutOutRestartSettings.ProbeInterval"/> to
|
||||||
|
/// <see cref="ShutOutRestartSettings.ProbeMaxInterval"/> (fixed at the short interval while a grace
|
||||||
|
/// runs), until that address is a member again. A probe of a restarted incarnation (new UID) is
|
||||||
|
/// harmless — the new incarnation is joining anyway; a probe of the old incarnation produces S2
|
||||||
|
/// here and S1 or S2 there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The restart</b> reuses the existing self-down path: <c>Cluster.Down(self)</c> ⇒
|
||||||
|
/// <c>run-coordinated-shutdown-when-down</c> ⇒ the <c>ActorSystem</c> terminates ⇒ the Host's
|
||||||
|
/// <c>WhenTerminated</c> watchdog exits the process with <see cref="AkkaHostedService.SelfDownExitCode"/>
|
||||||
|
/// ⇒ the service supervisor (docker <c>restart: unless-stopped</c>, Windows service recovery with
|
||||||
|
/// <c>failureflag 1</c>) starts a fresh incarnation that rejoins its peer as standby. If the system
|
||||||
|
/// has not terminated <see cref="FallbackShutdownAfter"/> after the Down, <c>CoordinatedShutdown</c>
|
||||||
|
/// is run directly.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ShutOutRestart : ShutOutRestartBase
|
||||||
|
{
|
||||||
|
/// <summary>How long after <c>Cluster.Down(self)</c> the direct <c>CoordinatedShutdown</c> fallback runs.</summary>
|
||||||
|
internal static readonly TimeSpan FallbackShutdownAfter = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
private readonly Akka.Cluster.Cluster _cluster;
|
||||||
|
private ICancelable? _fallback;
|
||||||
|
|
||||||
|
/// <summary>Props for the actor.</summary>
|
||||||
|
/// <param name="settings">The settings.</param>
|
||||||
|
/// <param name="cluster">The cluster extension.</param>
|
||||||
|
/// <returns>The props.</returns>
|
||||||
|
public static Props Props(ShutOutRestartSettings settings, Akka.Cluster.Cluster cluster) =>
|
||||||
|
Akka.Actor.Props.Create(() => new ShutOutRestart(settings, cluster));
|
||||||
|
|
||||||
|
/// <summary>Creates the actor.</summary>
|
||||||
|
/// <param name="settings">The settings.</param>
|
||||||
|
/// <param name="cluster">The cluster extension.</param>
|
||||||
|
public ShutOutRestart(ShutOutRestartSettings settings, Akka.Cluster.Cluster cluster)
|
||||||
|
: base(settings)
|
||||||
|
{
|
||||||
|
_cluster = cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Address SelfAddress => _cluster.SelfAddress;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override IScheduler Scheduler => Context.System.Scheduler;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override IEnumerable<Address> SeedNodes => _cluster.Settings.SeedNodes;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PreStart()
|
||||||
|
{
|
||||||
|
_cluster.Subscribe(Self, ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
|
||||||
|
typeof(ClusterEvent.IMemberEvent));
|
||||||
|
Context.System.EventStream.Subscribe(Self, typeof(ThisActorSystemQuarantinedEvent));
|
||||||
|
Context.System.EventStream.Subscribe(Self, typeof(AssociationErrorEvent));
|
||||||
|
base.PreStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PostStop()
|
||||||
|
{
|
||||||
|
_cluster.Unsubscribe(Self);
|
||||||
|
Context.System.EventStream.Unsubscribe(Self);
|
||||||
|
_fallback?.Cancel();
|
||||||
|
base.PostStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void RestartSelf()
|
||||||
|
{
|
||||||
|
_cluster.Down(_cluster.SelfAddress);
|
||||||
|
_fallback = Scheduler.ScheduleTellOnceCancelable(FallbackShutdownAfter, Self, FallbackShutdown.Instance, Self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Probe(Address peer)
|
||||||
|
{
|
||||||
|
Context.ActorSelection(new RootActorPath(peer) / "system" / "cluster" / "core" / "daemon")
|
||||||
|
.Tell(new Identify(ProbeMessageId), Self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnFallbackShutdown()
|
||||||
|
{
|
||||||
|
if (!Context.System.WhenTerminated.IsCompleted)
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] {0} was downed for a shut-out restart {1} ago but its actor system is still running; running CoordinatedShutdown directly",
|
||||||
|
SelfAddress, FallbackShutdownAfter);
|
||||||
|
CoordinatedShutdown.Get(Context.System).Run(CoordinatedShutdown.ClusterDowningReason.Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cluster-free decision logic of <see cref="ShutOutRestart"/>, split out (as
|
||||||
|
/// <see cref="LeaderAutoDownBase"/> is) so the tiebreak and the single- and both-sides cases can be
|
||||||
|
/// unit tested by feeding events straight to the actor. Every decision is logged with the
|
||||||
|
/// <c>[leader-auto-down]</c> prefix; the restart / stay verdicts at Warning.
|
||||||
|
/// </summary>
|
||||||
|
internal abstract class ShutOutRestartBase : UntypedActor
|
||||||
|
{
|
||||||
|
/// <summary>The <see cref="Identify.MessageId"/> carried by every probe.</summary>
|
||||||
|
internal const string ProbeMessageId = "scadabridge-shut-out-probe";
|
||||||
|
|
||||||
|
/// <summary>Timer: probe <see cref="Peer"/> again.</summary>
|
||||||
|
/// <param name="Peer">The removed peer to probe.</param>
|
||||||
|
internal sealed record ProbeTick(Address Peer);
|
||||||
|
|
||||||
|
/// <summary>Timer: the grace for <see cref="Peer"/> has ended.</summary>
|
||||||
|
/// <param name="Peer">The peer this node is split from.</param>
|
||||||
|
internal sealed record GraceExpired(Address Peer);
|
||||||
|
|
||||||
|
/// <summary>Timer: the direct-shutdown fallback after a restart was requested.</summary>
|
||||||
|
internal sealed class FallbackShutdown
|
||||||
|
{
|
||||||
|
/// <summary>The singleton instance.</summary>
|
||||||
|
public static readonly FallbackShutdown Instance = new();
|
||||||
|
|
||||||
|
private FallbackShutdown()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A peer identity independent of the transport scheme (the throttler adapter rewrites it).</summary>
|
||||||
|
private readonly record struct PeerKey(string System, string? Host, int? Port)
|
||||||
|
{
|
||||||
|
public static PeerKey Of(Address address) => new(address.System, address.Host, address.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProbeState
|
||||||
|
{
|
||||||
|
public required Address Address { get; init; }
|
||||||
|
public TimeSpan NextInterval { get; set; }
|
||||||
|
public ICancelable? Timer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase of a detected split with one peer.</summary>
|
||||||
|
private enum SplitPhase
|
||||||
|
{
|
||||||
|
/// <summary>S2 seen: the peer's old incarnation is alive; it gets a grace to restart itself.</summary>
|
||||||
|
Suspect,
|
||||||
|
|
||||||
|
/// <summary>Tiebreak said stay: the peer is expected to restart; it gets a grace to do so.</summary>
|
||||||
|
Winner,
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SplitState
|
||||||
|
{
|
||||||
|
public required SplitPhase Phase { get; set; }
|
||||||
|
public required ICancelable Timer { get; set; }
|
||||||
|
public DateTime LastEvidenceUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ShutOutRestartSettings _settings;
|
||||||
|
private readonly Dictionary<PeerKey, Member> _members = new();
|
||||||
|
private readonly HashSet<PeerKey> _knownPeers = new();
|
||||||
|
private readonly HashSet<PeerKey> _ignoredLogged = new();
|
||||||
|
private readonly Dictionary<PeerKey, ProbeState> _probing = new();
|
||||||
|
private readonly Dictionary<PeerKey, SplitState> _splits = new();
|
||||||
|
private bool _restarting;
|
||||||
|
|
||||||
|
/// <summary>Creates the base logic.</summary>
|
||||||
|
/// <param name="settings">The settings.</param>
|
||||||
|
protected ShutOutRestartBase(ShutOutRestartSettings settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decision log.</summary>
|
||||||
|
protected ILoggingAdapter Log { get; } = Context.GetLogger();
|
||||||
|
|
||||||
|
/// <summary>This node's address.</summary>
|
||||||
|
public abstract Address SelfAddress { get; }
|
||||||
|
|
||||||
|
/// <summary>The scheduler used for the probe and grace timers.</summary>
|
||||||
|
public abstract IScheduler Scheduler { get; }
|
||||||
|
|
||||||
|
/// <summary>The configured seed nodes; peers of this pair even before they were ever seen as members.</summary>
|
||||||
|
protected abstract IEnumerable<Address> SeedNodes { get; }
|
||||||
|
|
||||||
|
/// <summary>The clock; overridable for tests.</summary>
|
||||||
|
protected virtual DateTime UtcNow => DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Restarts this node (called at most once).</summary>
|
||||||
|
protected abstract void RestartSelf();
|
||||||
|
|
||||||
|
/// <summary>Sends one probe to <paramref name="peer"/>.</summary>
|
||||||
|
/// <param name="peer">The removed peer.</param>
|
||||||
|
protected abstract void Probe(Address peer);
|
||||||
|
|
||||||
|
/// <summary>Runs the direct-shutdown fallback, if the restart has not taken effect.</summary>
|
||||||
|
protected virtual void OnFallbackShutdown()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an association failure is S2: the handshake met a UID this node has quarantined.
|
||||||
|
/// Akka.Remote 1.5.62 carries no typed reason outward — the protocol layer's
|
||||||
|
/// <c>ForbiddenUidReason</c> becomes "The remote system has a UID that has been quarantined.
|
||||||
|
/// Association aborted." and the endpoint layer's <c>HopelessAssociation</c> says "The remote
|
||||||
|
/// system [..] has a UID [..] that has been quarantined." — so the cause chain is matched on that
|
||||||
|
/// wording. Pinned by the in-process scenario tests, which fail if an Akka upgrade rewords it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cause">The association error's cause.</param>
|
||||||
|
/// <returns><see langword="true"/> when the failure is a refused, quarantined UID.</returns>
|
||||||
|
internal static bool IsQuarantinedUidRefusal(Exception? cause)
|
||||||
|
{
|
||||||
|
for (var depth = 0; cause is not null && depth < 8; depth++)
|
||||||
|
{
|
||||||
|
var message = cause.Message;
|
||||||
|
if (message.Contains("has a UID", StringComparison.Ordinal)
|
||||||
|
&& message.Contains("that has been quarantined", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cause is AggregateException aggregate && aggregate.InnerExceptions.Any(IsQuarantinedUidRefusal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cause = cause.InnerException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PreStart()
|
||||||
|
{
|
||||||
|
foreach (var seed in SeedNodes)
|
||||||
|
{
|
||||||
|
AddKnownPeer(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Info(
|
||||||
|
"[leader-auto-down] shut-out restart is ON for {0}: a node split from its pair peer restarts itself — at once if only it "
|
||||||
|
+ "was removed, otherwise the higher address restarts after a {1} grace (ScadaBridge#132)",
|
||||||
|
SelfAddress, _settings.Grace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PostStop()
|
||||||
|
{
|
||||||
|
CancelAllTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnReceive(object message)
|
||||||
|
{
|
||||||
|
switch (message)
|
||||||
|
{
|
||||||
|
case ClusterEvent.CurrentClusterState state:
|
||||||
|
_members.Clear();
|
||||||
|
foreach (var member in state.Members)
|
||||||
|
{
|
||||||
|
TrackMember(member);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ClusterEvent.MemberRemoved removed:
|
||||||
|
_members.Remove(PeerKey.Of(removed.Member.Address));
|
||||||
|
if (removed.PreviousStatus == MemberStatus.Down && !IsSelf(removed.Member.Address))
|
||||||
|
{
|
||||||
|
StartProbing(removed.Member.Address);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ClusterEvent.IMemberEvent memberEvent:
|
||||||
|
TrackMember(memberEvent.Member);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ThisActorSystemQuarantinedEvent shutOut:
|
||||||
|
OnShutOutByPeer(shutOut.RemoteAddress);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AssociationErrorEvent error:
|
||||||
|
if (IsQuarantinedUidRefusal(error.Cause))
|
||||||
|
{
|
||||||
|
OnPeerStillRunningRemovedIncarnation(error.RemoteAddress);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ProbeTick tick:
|
||||||
|
OnProbeTick(tick.Peer);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case GraceExpired expired:
|
||||||
|
OnGraceExpired(expired.Peer);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FallbackShutdown:
|
||||||
|
OnFallbackShutdown();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ActorIdentity:
|
||||||
|
// A probe answered by a reachable incarnation — nothing to do.
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Unhandled(message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLiveStatus(MemberStatus status) =>
|
||||||
|
status is MemberStatus.Joining or MemberStatus.WeaklyUp or MemberStatus.Up or MemberStatus.Leaving;
|
||||||
|
|
||||||
|
private bool IsSelf(Address address) => PeerKey.Of(address) == PeerKey.Of(SelfAddress);
|
||||||
|
|
||||||
|
private void AddKnownPeer(Address address)
|
||||||
|
{
|
||||||
|
if (!IsSelf(address))
|
||||||
|
{
|
||||||
|
_knownPeers.Add(PeerKey.Of(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrackMember(Member member)
|
||||||
|
{
|
||||||
|
var key = PeerKey.Of(member.Address);
|
||||||
|
_members[key] = member;
|
||||||
|
AddKnownPeer(member.Address);
|
||||||
|
if (IsLiveStatus(member.Status) && _probing.Remove(key, out var probe))
|
||||||
|
{
|
||||||
|
probe.Timer?.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLiveMember(Address address, out MemberStatus status)
|
||||||
|
{
|
||||||
|
if (_members.TryGetValue(PeerKey.Of(address), out var member) && IsLiveStatus(member.Status))
|
||||||
|
{
|
||||||
|
status = member.Status;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
status = MemberStatus.Removed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Common gate for both signals: ignore after a restart, for self, and for strangers.</summary>
|
||||||
|
private bool IsRelevantPeer(Address peer, string what)
|
||||||
|
{
|
||||||
|
if (_restarting || IsSelf(peer))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = PeerKey.Of(peer);
|
||||||
|
if (_knownPeers.Contains(key))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ignoredLogged.Add(key))
|
||||||
|
{
|
||||||
|
Log.Info("[leader-auto-down] {0} {1}, but it is not a seed or member of this cluster; ignored", peer, what);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S1: <paramref name="peer"/> has quarantined this node.</summary>
|
||||||
|
private void OnShutOutByPeer(Address peer)
|
||||||
|
{
|
||||||
|
if (!IsRelevantPeer(peer, $"has quarantined this node ({SelfAddress})"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLiveMember(peer, out var status))
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] shut out by {0}: it has removed and quarantined this node ({1}) while this node still counts it as a member ({2}), "
|
||||||
|
+ "so only this side is shut out; tiebreak not needed — this node RESTARTS to rejoin as a fresh incarnation",
|
||||||
|
peer, SelfAddress, status);
|
||||||
|
Restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_splits.TryGetValue(PeerKey.Of(peer), out var split))
|
||||||
|
{
|
||||||
|
split.LastEvidenceUtc = UtcNow;
|
||||||
|
if (split.Phase == SplitPhase.Winner)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspect → the peer has quarantined us too: both sides are shut out, decide now.
|
||||||
|
split.Timer.Cancel();
|
||||||
|
_splits.Remove(PeerKey.Of(peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
Tiebreak(peer, $"shut out by {peer}: it has quarantined this node ({SelfAddress}), and this node has removed it too");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S2: <paramref name="peer"/> is still running the incarnation this node removed.</summary>
|
||||||
|
private void OnPeerStillRunningRemovedIncarnation(Address peer)
|
||||||
|
{
|
||||||
|
if (!IsRelevantPeer(peer, "refused a handshake with a UID this node has quarantined"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = PeerKey.Of(peer);
|
||||||
|
if (_splits.TryGetValue(key, out var split))
|
||||||
|
{
|
||||||
|
split.LastEvidenceUtc = UtcNow;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLiveMember(peer, out _))
|
||||||
|
{
|
||||||
|
// A live member with a quarantined UID is a transient of Akka's own quarantine path
|
||||||
|
// (it is about to be marked terminated and downed); the downing provider handles it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] split from {0}: it is still running the incarnation this node ({1}) removed, so this node has shut it out. "
|
||||||
|
+ "Giving it {2} to restart itself before the tiebreak (a frozen node that was removed restarts on its own)",
|
||||||
|
peer, SelfAddress, _settings.Grace);
|
||||||
|
StartSplit(peer, SplitPhase.Suspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tiebreak(Address peer, string why)
|
||||||
|
{
|
||||||
|
if (Member.AddressOrdering.Compare(SelfAddress, peer) > 0)
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] {0}; both sides are shut out. Tiebreak says RESTART: this node ({1}) has the higher address",
|
||||||
|
why, SelfAddress);
|
||||||
|
Restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] {0}; both sides are shut out. Tiebreak says STAY: this node ({1}) has the lower address, so {2} restarts. "
|
||||||
|
+ "This node restarts too only if {2} has not rejoined and is still split from it after {3}",
|
||||||
|
why, SelfAddress, peer, _settings.Grace);
|
||||||
|
StartSplit(peer, SplitPhase.Winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartSplit(Address peer, SplitPhase phase)
|
||||||
|
{
|
||||||
|
_splits[PeerKey.Of(peer)] = new SplitState
|
||||||
|
{
|
||||||
|
Phase = phase,
|
||||||
|
Timer = Scheduler.ScheduleTellOnceCancelable(_settings.Grace, Self, new GraceExpired(peer), Self),
|
||||||
|
LastEvidenceUtc = UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe at the short interval during the grace, so "still split" is measured.
|
||||||
|
StartProbing(peer, restartBackOff: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGraceExpired(Address peer)
|
||||||
|
{
|
||||||
|
if (_restarting || !_splits.Remove(PeerKey.Of(peer), out var split))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLiveMember(peer, out var status))
|
||||||
|
{
|
||||||
|
Log.Info(
|
||||||
|
"[leader-auto-down] {0} rejoined ({1}) within the shut-out grace; this node ({2}) stays",
|
||||||
|
peer, status, SelfAddress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sinceLast = UtcNow - split.LastEvidenceUtc;
|
||||||
|
if (sinceLast > _settings.RecentWindow)
|
||||||
|
{
|
||||||
|
Log.Info(
|
||||||
|
"[leader-auto-down] {0} has not rejoined yet, but nothing of its old incarnation has been seen for {1:0}s (it is restarting or down); "
|
||||||
|
+ "this node ({2}) stays",
|
||||||
|
peer, sinceLast.TotalSeconds, SelfAddress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (split.Phase == SplitPhase.Suspect)
|
||||||
|
{
|
||||||
|
Tiebreak(peer,
|
||||||
|
$"split from {peer}: its old incarnation is still running {_settings.Grace} later and it has not restarted itself");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning(
|
||||||
|
"[leader-auto-down] {0} is still split from this node ({1}) {2} after the tiebreak (last seen {3:0}s ago) and has not rejoined, "
|
||||||
|
+ "so this node is the stale side after all; this node RESTARTS",
|
||||||
|
peer, SelfAddress, _settings.Grace, sinceLast.TotalSeconds);
|
||||||
|
Restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartProbing(Address peer, bool restartBackOff = false)
|
||||||
|
{
|
||||||
|
var key = PeerKey.Of(peer);
|
||||||
|
if (_probing.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
if (!restartBackOff)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Timer?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = existing ?? new ProbeState { Address = peer };
|
||||||
|
state.NextInterval = _settings.ProbeInterval;
|
||||||
|
state.Timer = Scheduler.ScheduleTellOnceCancelable(state.NextInterval, Self, new ProbeTick(peer), Self);
|
||||||
|
_probing[key] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProbeTick(Address peer)
|
||||||
|
{
|
||||||
|
var key = PeerKey.Of(peer);
|
||||||
|
if (_restarting || !_probing.TryGetValue(key, out var state))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Probe(state.Address);
|
||||||
|
|
||||||
|
// Fixed short interval while a grace runs (to measure "still split"); otherwise back off.
|
||||||
|
state.NextInterval = _splits.ContainsKey(key)
|
||||||
|
? _settings.ProbeInterval
|
||||||
|
: TimeSpan.FromTicks(Math.Min(state.NextInterval.Ticks * 2, _settings.ProbeMaxInterval.Ticks));
|
||||||
|
state.Timer = Scheduler.ScheduleTellOnceCancelable(state.NextInterval, Self, new ProbeTick(peer), Self);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Restart()
|
||||||
|
{
|
||||||
|
_restarting = true;
|
||||||
|
CancelAllTimers();
|
||||||
|
_probing.Clear();
|
||||||
|
_splits.Clear();
|
||||||
|
RestartSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAllTimers()
|
||||||
|
{
|
||||||
|
foreach (var probe in _probing.Values)
|
||||||
|
{
|
||||||
|
probe.Timer?.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var split in _splits.Values)
|
||||||
|
{
|
||||||
|
split.Timer.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,11 @@ Log.Logger = ZB.MOM.WW.ScadaBridge.Host.ScadaBridgeLogging
|
|||||||
// The catch below uses it to tell that routine stop from a genuine fatal exit (ScadaBridge#76).
|
// The catch below uses it to tell that routine stop from a genuine fatal exit (ScadaBridge#76).
|
||||||
var hostStopping = CancellationToken.None;
|
var hostStopping = CancellationToken.None;
|
||||||
|
|
||||||
|
// The Akka hosted service, captured with hostStopping (RunAsync disposes the host's service
|
||||||
|
// provider), so the finally block can turn an Akka watchdog stop (self-down / ScadaBridge#132
|
||||||
|
// shut-out restart) into a non-zero exit code whichever way RunAsync unwound.
|
||||||
|
AkkaHostedService? akkaHostedService = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname);
|
Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname);
|
||||||
@@ -670,6 +675,7 @@ try
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
hostStopping = app.Lifetime.ApplicationStopping;
|
hostStopping = app.Lifetime.ApplicationStopping;
|
||||||
|
akkaHostedService = app.Services.GetService<AkkaHostedService>();
|
||||||
|
|
||||||
// Apply or validate database migrations (skip when running in test harness)
|
// Apply or validate database migrations (skip when running in test harness)
|
||||||
if (!string.Equals(configuration["ScadaBridge:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(configuration["ScadaBridge:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -864,6 +870,7 @@ try
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
hostStopping = app.Lifetime.ApplicationStopping;
|
hostStopping = app.Lifetime.ApplicationStopping;
|
||||||
|
akkaHostedService = app.Services.GetService<AkkaHostedService>();
|
||||||
|
|
||||||
// Endpoint routing middleware. The gRPC service mapping below and the
|
// Endpoint routing middleware. The gRPC service mapping below and the
|
||||||
// /metrics scrape endpoint both run on endpoint routing, so UseRouting()
|
// /metrics scrape endpoint both run on endpoint routing, so UseRouting()
|
||||||
@@ -934,6 +941,8 @@ catch (Exception ex)
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
Program.SetSelfDownExitCode(akkaHostedService);
|
||||||
|
|
||||||
await Log.CloseAndFlushAsync();
|
await Log.CloseAndFlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,6 +964,25 @@ public partial class Program
|
|||||||
internal static bool IsGracefulShutdown(Exception ex, CancellationToken hostStopping) =>
|
internal static bool IsGracefulShutdown(Exception ex, CancellationToken hostStopping) =>
|
||||||
ex is OperationCanceledException && hostStopping.IsCancellationRequested;
|
ex is OperationCanceledException && hostStopping.IsCancellationRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After <c>RunAsync</c> returns: when the stop was the Akka watchdog's (the ActorSystem
|
||||||
|
/// terminated outside host shutdown — a self-down or a ScadaBridge#132 shut-out restart), exit
|
||||||
|
/// with the non-zero <see cref="AkkaHostedService.SelfDownExitCode"/> so the service supervisor
|
||||||
|
/// restarts the node. Docker <c>restart: unless-stopped</c> restarts on any exit; Windows service
|
||||||
|
/// recovery gets the code through the service lifetime (<c>AkkaHostedService.MarkServiceSelfDownExit</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="akka">The host's Akka hosted service; null if the host never built.</param>
|
||||||
|
internal static void SetSelfDownExitCode(AkkaHostedService? akka)
|
||||||
|
{
|
||||||
|
if (akka is { TerminatedOutsideHostShutdown: true })
|
||||||
|
{
|
||||||
|
Environment.ExitCode = AkkaHostedService.SelfDownExitCode;
|
||||||
|
Log.Warning(
|
||||||
|
"ScadaBridge host stopped because its actor system terminated (self-down / shut-out restart); exiting with code {ExitCode} so the service supervisor restarts it",
|
||||||
|
AkkaHostedService.SelfDownExitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the
|
/// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the
|
||||||
/// <c>ASPNETCORE_URLS</c> / <c>--urls</c> value). Port-only view of
|
/// <c>ASPNETCORE_URLS</c> / <c>--urls</c> value). Port-only view of
|
||||||
|
|||||||
+28
@@ -327,4 +327,32 @@ public class ClusterOptionsValidatorTests
|
|||||||
Assert.True(result.Failed);
|
Assert.True(result.Failed);
|
||||||
Assert.Contains("'leader-auto-down'", result.FailureMessage);
|
Assert.Contains("'leader-auto-down'", result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132: a non-positive shut-out grace is refused under leader-auto-down with the
|
||||||
|
/// restart on (it would make both sides of a healed partition restart together), and ignored
|
||||||
|
/// when the restart is off or another strategy is selected.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="strategy">The strategy.</param>
|
||||||
|
/// <param name="restart">The shut-out restart switch.</param>
|
||||||
|
/// <param name="expectValid">Whether validation must pass.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("leader-auto-down", true, false)]
|
||||||
|
[InlineData("leader-auto-down", false, true)]
|
||||||
|
[InlineData("auto-down", true, true)]
|
||||||
|
public void LeaderAutoDown_ZeroShutOutGrace(string strategy, bool restart, bool expectValid)
|
||||||
|
{
|
||||||
|
var options = ValidOptions();
|
||||||
|
options.SplitBrainResolverStrategy = strategy;
|
||||||
|
options.LeaderAutoDown = new LeaderAutoDownOptions { RestartWhenShutOut = restart, ShutOutGrace = TimeSpan.Zero };
|
||||||
|
|
||||||
|
var result = new ClusterOptionsValidator().Validate(null, options);
|
||||||
|
|
||||||
|
Assert.Equal(expectValid, result.Succeeded);
|
||||||
|
if (!expectValid)
|
||||||
|
{
|
||||||
|
Assert.Contains("ShutOutGrace", result.FailureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,4 +322,46 @@ public class HoconBuilderTests
|
|||||||
Assert.DoesNotContain("leader-auto-down", hocon);
|
Assert.DoesNotContain("leader-auto-down", hocon);
|
||||||
Assert.DoesNotContain(nameof(LeaderAutoDowningProvider), hocon);
|
Assert.DoesNotContain(nameof(LeaderAutoDowningProvider), hocon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132: leader-auto-down carries the shut-out restart switch (default ON) and its
|
||||||
|
/// grace into the provider's HOCON block, and the provider reads back exactly those values.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="enabled">The configured switch.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void BuildHocon_LeaderAutoDown_EmitsShutOutRestartSettings(bool enabled)
|
||||||
|
{
|
||||||
|
var cluster = DefaultCluster();
|
||||||
|
cluster.SplitBrainResolverStrategy = "leader-auto-down";
|
||||||
|
cluster.LeaderAutoDown = new LeaderAutoDownOptions
|
||||||
|
{
|
||||||
|
RestartWhenShutOut = enabled,
|
||||||
|
ShutOutGrace = TimeSpan.FromMilliseconds(45500),
|
||||||
|
};
|
||||||
|
|
||||||
|
var config = ConfigurationFactory.ParseString(AkkaHostedService.BuildHocon(
|
||||||
|
DefaultNode(), cluster, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||||
|
|
||||||
|
var settings = ShutOutRestartSettings.FromConfig(config);
|
||||||
|
Assert.Equal(enabled, settings.Enabled);
|
||||||
|
Assert.Equal(TimeSpan.FromMilliseconds(45500), settings.Grace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#132: the shut-out restart is ON by default when leader-auto-down is selected.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildHocon_LeaderAutoDown_ShutOutRestartOnByDefault()
|
||||||
|
{
|
||||||
|
var cluster = DefaultCluster();
|
||||||
|
cluster.SplitBrainResolverStrategy = "leader-auto-down";
|
||||||
|
|
||||||
|
var config = ConfigurationFactory.ParseString(AkkaHostedService.BuildHocon(
|
||||||
|
DefaultNode(), cluster, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)));
|
||||||
|
|
||||||
|
Assert.True(config.GetBoolean(ShutOutRestartSettings.EnabledPath));
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(60), config.GetTimeSpan(ShutOutRestartSettings.GracePath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Reflection;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using Akka.Remote;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Akka.Util;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132: the decision logic of <see cref="ShutOutRestartBase"/>. Cluster events and
|
||||||
|
/// <see cref="ThisActorSystemQuarantinedEvent"/> are fed straight to the actor; the restart and
|
||||||
|
/// probe seams report to the test actor. Covers the single-side shut-out (restart, no tiebreak),
|
||||||
|
/// the tiebreak in both orders, the both-sides case where exactly one of two nodes restarts, the
|
||||||
|
/// winner's grace, and the probing that makes a healed partition produce the event at all. The
|
||||||
|
/// real two-node behaviour is proven in IntegrationTests (<c>ShutOutRestartScenarioTests</c>).
|
||||||
|
/// </summary>
|
||||||
|
public class ShutOutRestartTests : TestKit
|
||||||
|
{
|
||||||
|
/// <summary>INFO so the decision-log assertions see every line.</summary>
|
||||||
|
public ShutOutRestartTests()
|
||||||
|
: base("akka.loglevel = INFO")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// "a" < "b" < "c" by Akka's member address ordering (host, then port).
|
||||||
|
private static readonly Address NodeA = new("akka.tcp", "scadabridge", "a", 8081);
|
||||||
|
private static readonly Address NodeB = new("akka.tcp", "scadabridge", "b", 8081);
|
||||||
|
private static readonly Address Stranger = new("akka.tcp", "scadabridge", "z", 9999);
|
||||||
|
|
||||||
|
private static readonly ShutOutRestartSettings Fast = new(
|
||||||
|
Enabled: true,
|
||||||
|
Grace: TimeSpan.FromSeconds(1.5),
|
||||||
|
ProbeInterval: TimeSpan.FromMilliseconds(200),
|
||||||
|
ProbeMaxInterval: TimeSpan.FromMilliseconds(800));
|
||||||
|
|
||||||
|
private sealed record Restarted(Address Self);
|
||||||
|
|
||||||
|
private sealed record Probed(Address Self, Address Peer);
|
||||||
|
|
||||||
|
/// <summary>Test double: restart and probe report to a probe actor.</summary>
|
||||||
|
private sealed class TestShutOutRestart : ShutOutRestartBase
|
||||||
|
{
|
||||||
|
private readonly IActorRef _probe;
|
||||||
|
private readonly Address[] _seeds;
|
||||||
|
|
||||||
|
public TestShutOutRestart(Address self, Address[] seeds, ShutOutRestartSettings settings, IActorRef probe)
|
||||||
|
: base(settings)
|
||||||
|
{
|
||||||
|
SelfAddress = self;
|
||||||
|
_seeds = seeds;
|
||||||
|
_probe = probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Address SelfAddress { get; }
|
||||||
|
|
||||||
|
public override IScheduler Scheduler => Context.System.Scheduler;
|
||||||
|
|
||||||
|
protected override IEnumerable<Address> SeedNodes => _seeds;
|
||||||
|
|
||||||
|
protected override void RestartSelf() => _probe.Tell(new Restarted(SelfAddress));
|
||||||
|
|
||||||
|
protected override void Probe(Address peer) => _probe.Tell(new Probed(SelfAddress, peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Member CreateMember(Address address, MemberStatus status)
|
||||||
|
{
|
||||||
|
var create = typeof(Member).GetMethod(
|
||||||
|
"Create",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic,
|
||||||
|
new[] { typeof(UniqueAddress), typeof(int), typeof(MemberStatus), typeof(ImmutableHashSet<string>), typeof(AppVersion) })
|
||||||
|
?? throw new InvalidOperationException("Akka.Cluster.Member.Create(…) not found — Akka internals changed.");
|
||||||
|
return (Member)create.Invoke(null, new object[]
|
||||||
|
{
|
||||||
|
new UniqueAddress(address, 1), 1, status, ImmutableHashSet<string>.Empty, AppVersion.Zero,
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClusterEvent.CurrentClusterState StateWith(params Member[] members) =>
|
||||||
|
new(
|
||||||
|
ImmutableSortedSet.CreateRange(members),
|
||||||
|
ImmutableHashSet<Member>.Empty,
|
||||||
|
ImmutableHashSet<Address>.Empty,
|
||||||
|
null,
|
||||||
|
ImmutableDictionary<string, Address>.Empty);
|
||||||
|
|
||||||
|
private IActorRef Node(Address self, Address peer, IActorRef? probe = null, ShutOutRestartSettings? settings = null) =>
|
||||||
|
Sys.ActorOf(Props.Create(() => new TestShutOutRestart(
|
||||||
|
self, new[] { self, peer }, settings ?? Fast, probe ?? TestActor)));
|
||||||
|
|
||||||
|
/// <summary>Both nodes see each other Up, then each removes the other after downing it (the partition outcome).</summary>
|
||||||
|
private static void BothRemovedEachOther(IActorRef node, Address self, Address peer)
|
||||||
|
{
|
||||||
|
node.Tell(StateWith(CreateMember(self, MemberStatus.Up), CreateMember(peer, MemberStatus.Up)));
|
||||||
|
node.Tell(new ClusterEvent.MemberRemoved(CreateMember(peer, MemberStatus.Removed), MemberStatus.Down));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThisActorSystemQuarantinedEvent ShutOutBy(Address peer, Address self) => new(self, peer);
|
||||||
|
|
||||||
|
/// <summary>S2 as Akka.Remote 1.5.62 surfaces it: the endpoint writer's InvalidAssociation wrapping the protocol refusal.</summary>
|
||||||
|
private static AssociationErrorEvent StillRunningRemoved(Address peer, Address self) =>
|
||||||
|
new(
|
||||||
|
new Exception(
|
||||||
|
$"Association failed with {peer}",
|
||||||
|
new AggregateException(new Exception("The remote system has a UID that has been quarantined. Association aborted."))),
|
||||||
|
self, peer, inbound: false, Akka.Event.LogLevel.WarningLevel);
|
||||||
|
|
||||||
|
/// <summary>Everything <paramref name="probe"/> receives during <paramref name="window"/>.</summary>
|
||||||
|
private static List<object> Drain(Akka.TestKit.TestProbe probe, TimeSpan window)
|
||||||
|
{
|
||||||
|
var received = new List<object>();
|
||||||
|
var until = DateTime.UtcNow + window;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var left = until - DateTime.UtcNow;
|
||||||
|
if (left <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return received;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probe.TryReceiveOne(out var envelope, left))
|
||||||
|
{
|
||||||
|
received.Add(envelope.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single side (the frozen node): the peer is still a live member in this node's view, so only
|
||||||
|
/// this side can be shut out — it restarts at once, whichever address is higher.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="selfIsA">Whether this node is the lower-address node.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void ShutOut_PeerStillAMember_RestartsWithoutTiebreak(bool selfIsA)
|
||||||
|
{
|
||||||
|
var (self, peer) = selfIsA ? (NodeA, NodeB) : (NodeB, NodeA);
|
||||||
|
var node = Node(self, peer);
|
||||||
|
node.Tell(StateWith(CreateMember(self, MemberStatus.Up), CreateMember(peer, MemberStatus.Up)));
|
||||||
|
|
||||||
|
EventFilter.Warning(contains: "tiebreak not needed").ExpectOne(() => node.Tell(ShutOutBy(peer, self)));
|
||||||
|
ExpectMsg(new Restarted(self));
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tiebreak, higher address: both sides removed each other; the higher address restarts at once.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ShutOut_PeerRemoved_HigherAddress_Restarts()
|
||||||
|
{
|
||||||
|
var node = Node(NodeB, NodeA);
|
||||||
|
BothRemovedEachOther(node, NodeB, NodeA);
|
||||||
|
|
||||||
|
EventFilter.Warning(contains: "Tiebreak says RESTART").ExpectOne(() => node.Tell(ShutOutBy(NodeA, NodeB)));
|
||||||
|
ExpectMsg(new Restarted(NodeB));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreak, lower address: it stays; it keeps probing during the grace, and at the end of the
|
||||||
|
/// grace — the peer silent, i.e. it restarted — it still stays.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ShutOut_PeerRemoved_LowerAddress_Stays_AndStaysAfterGraceWhenPeerWentQuiet()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeA, NodeB, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeA, NodeB);
|
||||||
|
|
||||||
|
EventFilter.Warning(contains: "Tiebreak says STAY").ExpectOne(() => node.Tell(ShutOutBy(NodeB, NodeA)));
|
||||||
|
EventFilter.Info(contains: "nothing of its old incarnation has been seen").ExpectOne(TimeSpan.FromSeconds(4), () => { });
|
||||||
|
|
||||||
|
var received = Drain(probe, TimeSpan.FromMilliseconds(500));
|
||||||
|
Assert.Contains(new Probed(NodeA, NodeB), received); // it kept probing its peer
|
||||||
|
Assert.DoesNotContain(new Restarted(NodeA), received); // and never restarted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreak, lower address, but the peer keeps refusing for the whole grace and never rejoins:
|
||||||
|
/// the lower node was the stale side after all (single-sided with the lower address) and restarts.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutOut_PeerRemoved_LowerAddress_StillRefusedAfterGrace_Restarts()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeA, NodeB, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeA, NodeB);
|
||||||
|
node.Tell(ShutOutBy(NodeB, NodeA));
|
||||||
|
|
||||||
|
var until = DateTime.UtcNow + Fast.Grace + TimeSpan.FromSeconds(1);
|
||||||
|
var restarted = false;
|
||||||
|
while (DateTime.UtcNow < until && !restarted)
|
||||||
|
{
|
||||||
|
node.Tell(ShutOutBy(NodeB, NodeA)); // every probe is refused
|
||||||
|
await Task.Delay(100);
|
||||||
|
while (probe.HasMessages)
|
||||||
|
{
|
||||||
|
restarted |= probe.ReceiveOne(TimeSpan.Zero) is Restarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(restarted, "the lower node never restarted although its peer kept refusing it past the grace");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tiebreak, lower address: the peer rejoins during the grace; the lower node stays.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ShutOut_PeerRemoved_LowerAddress_PeerRejoins_Stays()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeA, NodeB, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeA, NodeB);
|
||||||
|
node.Tell(ShutOutBy(NodeB, NodeA));
|
||||||
|
node.Tell(ShutOutBy(NodeB, NodeA)); // refused right up to the restart of the peer
|
||||||
|
node.Tell(new ClusterEvent.MemberUp(CreateMember(NodeB, MemberStatus.Up)));
|
||||||
|
|
||||||
|
EventFilter.Info(contains: "rejoined").ExpectOne(TimeSpan.FromSeconds(4), () => { });
|
||||||
|
Assert.DoesNotContain(new Restarted(NodeA), Drain(probe, TimeSpan.FromMilliseconds(500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Both sides shut out (a healed partition): two nodes, each having removed the other, both
|
||||||
|
/// receive the event — exactly ONE restarts, and it is the higher address. Run in both
|
||||||
|
/// delivery orders so the outcome cannot depend on who hears first.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="higherHearsFirst">Whether the higher-address node receives its event first.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void BothSidesShutOut_ExactlyOneRestarts_TheHigherAddress(bool higherHearsFirst)
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var a = Node(NodeA, NodeB, probe.Ref);
|
||||||
|
var b = Node(NodeB, NodeA, probe.Ref);
|
||||||
|
BothRemovedEachOther(a, NodeA, NodeB);
|
||||||
|
BothRemovedEachOther(b, NodeB, NodeA);
|
||||||
|
|
||||||
|
if (higherHearsFirst)
|
||||||
|
{
|
||||||
|
b.Tell(ShutOutBy(NodeA, NodeB));
|
||||||
|
a.Tell(ShutOutBy(NodeB, NodeA));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
a.Tell(ShutOutBy(NodeB, NodeA));
|
||||||
|
b.Tell(ShutOutBy(NodeA, NodeB));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within the grace, the only restart is B's.
|
||||||
|
var restarts = Drain(probe, Fast.Grace - TimeSpan.FromMilliseconds(300)).OfType<Restarted>().ToList();
|
||||||
|
Assert.Equal(new[] { new Restarted(NodeB) }, restarts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The same event delivered many times restarts once.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedShutOut_RestartsOnce()
|
||||||
|
{
|
||||||
|
var node = Node(NodeB, NodeA);
|
||||||
|
node.Tell(StateWith(CreateMember(NodeB, MemberStatus.Up), CreateMember(NodeA, MemberStatus.Up)));
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
node.Tell(ShutOutBy(NodeA, NodeB));
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpectMsg(new Restarted(NodeB));
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A quarantine by an address that is neither a seed nor a member is not this pair's business.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ShutOutByStranger_IsIgnored()
|
||||||
|
{
|
||||||
|
var node = Node(NodeB, NodeA);
|
||||||
|
node.Tell(StateWith(CreateMember(NodeB, MemberStatus.Up), CreateMember(NodeA, MemberStatus.Up)));
|
||||||
|
|
||||||
|
EventFilter.Info(contains: "not a seed or member of this cluster").ExpectOne(() => node.Tell(ShutOutBy(Stranger, NodeB)));
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A peer removed after being DOWNED is probed with a back-off until it is a member again; a
|
||||||
|
/// peer that LEFT (Exiting → Removed) is not probed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DownedPeer_IsProbedUntilItRejoins_LeftPeerIsNot()
|
||||||
|
{
|
||||||
|
var node = Node(NodeA, NodeB);
|
||||||
|
node.Tell(StateWith(CreateMember(NodeA, MemberStatus.Up), CreateMember(NodeB, MemberStatus.Up)));
|
||||||
|
|
||||||
|
node.Tell(new ClusterEvent.MemberRemoved(CreateMember(NodeB, MemberStatus.Removed), MemberStatus.Exiting));
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(600));
|
||||||
|
|
||||||
|
node.Tell(new ClusterEvent.MemberRemoved(CreateMember(NodeB, MemberStatus.Removed), MemberStatus.Down));
|
||||||
|
ExpectMsg(new Probed(NodeA, NodeB), TimeSpan.FromSeconds(1));
|
||||||
|
ExpectMsg(new Probed(NodeA, NodeB), TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
node.Tell(new ClusterEvent.MemberJoined(CreateMember(NodeB, MemberStatus.Joining)));
|
||||||
|
ReceiveWhile<object>(TimeSpan.FromMilliseconds(100), m => m); // a tick already in flight
|
||||||
|
ExpectNoMsg(TimeSpan.FromSeconds(1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S2 on the higher node (it removed a peer whose old incarnation is still alive): no restart
|
||||||
|
/// during the grace — a frozen peer gets the chance to restart itself — then, still split, the
|
||||||
|
/// tiebreak restarts it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StillRunningRemoved_HigherAddress_WaitsGrace_ThenRestartsIfStillSplit()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeB, NodeA, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeB, NodeA);
|
||||||
|
|
||||||
|
EventFilter.Warning(contains: "Giving it").ExpectOne(() => node.Tell(StillRunningRemoved(NodeA, NodeB)));
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
|
var restartedAt = (DateTime?)null;
|
||||||
|
while (DateTime.UtcNow - started < Fast.Grace + TimeSpan.FromSeconds(1.5) && restartedAt is null)
|
||||||
|
{
|
||||||
|
node.Tell(StillRunningRemoved(NodeA, NodeB)); // every probe still meets the old incarnation
|
||||||
|
await Task.Delay(100);
|
||||||
|
while (probe.HasMessages)
|
||||||
|
{
|
||||||
|
if (probe.ReceiveOne(TimeSpan.Zero) is Restarted)
|
||||||
|
{
|
||||||
|
restartedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(restartedAt);
|
||||||
|
Assert.True(restartedAt.Value - started >= Fast.Grace - TimeSpan.FromMilliseconds(200),
|
||||||
|
$"restarted after {(restartedAt.Value - started).TotalSeconds:0.0}s, before the {Fast.Grace} grace");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The freeze survivor: S2 (the frozen node's old incarnation is alive), then the frozen node
|
||||||
|
/// restarts itself and its old incarnation goes quiet — the survivor does NOT restart, even
|
||||||
|
/// though it has the higher address.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void StillRunningRemoved_PeerGoesQuiet_NoRestartEvenWhenHigher()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeB, NodeA, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeB, NodeA);
|
||||||
|
node.Tell(StillRunningRemoved(NodeA, NodeB));
|
||||||
|
|
||||||
|
EventFilter.Info(contains: "nothing of its old incarnation has been seen").ExpectOne(TimeSpan.FromSeconds(4), () => { });
|
||||||
|
Assert.DoesNotContain(new Restarted(NodeB), Drain(probe, TimeSpan.FromMilliseconds(500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S2, then the peer's new incarnation joins during the grace: no restart.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void StillRunningRemoved_PeerRejoins_NoRestart()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var node = Node(NodeB, NodeA, probe.Ref);
|
||||||
|
BothRemovedEachOther(node, NodeB, NodeA);
|
||||||
|
node.Tell(StillRunningRemoved(NodeA, NodeB));
|
||||||
|
node.Tell(new ClusterEvent.MemberUp(CreateMember(NodeA, MemberStatus.Up)));
|
||||||
|
node.Tell(StillRunningRemoved(NodeA, NodeB)); // stale evidence from before the restart
|
||||||
|
|
||||||
|
EventFilter.Info(contains: "rejoined").ExpectOne(TimeSpan.FromSeconds(4), () => { });
|
||||||
|
Assert.DoesNotContain(new Restarted(NodeB), Drain(probe, TimeSpan.FromMilliseconds(500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A healed partition as Akka 1.5.62 really reports it — S2 on BOTH sides, no S1 — ends with
|
||||||
|
/// exactly one restart, the higher address, after the grace; the lower stays.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task BothSidesStillRunningRemoved_ExactlyOneRestarts_TheHigherAddress()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var a = Node(NodeA, NodeB, probe.Ref);
|
||||||
|
var b = Node(NodeB, NodeA, probe.Ref);
|
||||||
|
BothRemovedEachOther(a, NodeA, NodeB);
|
||||||
|
BothRemovedEachOther(b, NodeB, NodeA);
|
||||||
|
|
||||||
|
var restarts = new List<Restarted>();
|
||||||
|
var until = DateTime.UtcNow + Fast.Grace + Fast.Grace - TimeSpan.FromMilliseconds(400);
|
||||||
|
while (DateTime.UtcNow < until)
|
||||||
|
{
|
||||||
|
if (!restarts.Contains(new Restarted(NodeB)))
|
||||||
|
{
|
||||||
|
// Until B's old incarnation is gone, each side's probes meet the other's.
|
||||||
|
a.Tell(StillRunningRemoved(NodeB, NodeA));
|
||||||
|
b.Tell(StillRunningRemoved(NodeA, NodeB));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(100);
|
||||||
|
while (probe.HasMessages)
|
||||||
|
{
|
||||||
|
if (probe.ReceiveOne(TimeSpan.Zero) is Restarted r)
|
||||||
|
{
|
||||||
|
restarts.Add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(new[] { new Restarted(NodeB) }, restarts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S1 arriving while an S2 grace runs: the peer has quarantined us too, so the tiebreak runs at once.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void StillRunningRemoved_ThenShutOut_TiebreaksImmediately()
|
||||||
|
{
|
||||||
|
var node = Node(NodeB, NodeA);
|
||||||
|
BothRemovedEachOther(node, NodeB, NodeA);
|
||||||
|
node.Tell(StillRunningRemoved(NodeA, NodeB));
|
||||||
|
|
||||||
|
EventFilter.Warning(contains: "Tiebreak says RESTART").ExpectOne(() => node.Tell(ShutOutBy(NodeA, NodeB)));
|
||||||
|
FishForMessage(m => m is Restarted, TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>S2 detection matches Akka 1.5.62's two wordings anywhere in the cause chain, and nothing else.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void IsQuarantinedUidRefusal_MatchesAkkaWordingOnly()
|
||||||
|
{
|
||||||
|
Assert.True(ShutOutRestartBase.IsQuarantinedUidRefusal(
|
||||||
|
new Exception("Association failed with x", new AggregateException(
|
||||||
|
new Exception("The remote system has a UID that has been quarantined. Association aborted.")))));
|
||||||
|
Assert.True(ShutOutRestartBase.IsQuarantinedUidRefusal(
|
||||||
|
new Exception("The remote system [akka.tcp://scadabridge@b:8081] has a UID [42] that has been quarantined. Association aborted.")));
|
||||||
|
Assert.False(ShutOutRestartBase.IsQuarantinedUidRefusal(
|
||||||
|
new Exception("Association failed with x", new Exception("Connection refused"))));
|
||||||
|
Assert.False(ShutOutRestartBase.IsQuarantinedUidRefusal(
|
||||||
|
new Exception("The remote system has quarantined this system. No further associations to the remote system are possible until this system is restarted.")));
|
||||||
|
Assert.False(ShutOutRestartBase.IsQuarantinedUidRefusal(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Settings: defaults when keys are missing; the switch and durations are read when present.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Settings_FromConfig_DefaultsAndOverrides()
|
||||||
|
{
|
||||||
|
var defaults = ShutOutRestartSettings.FromConfig(ConfigurationFactory.Empty);
|
||||||
|
Assert.Equal(new ShutOutRestartSettings(true, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)), defaults);
|
||||||
|
|
||||||
|
var set = ShutOutRestartSettings.FromConfig(ConfigurationFactory.ParseString($@"
|
||||||
|
{ShutOutRestartSettings.EnabledPath} = off
|
||||||
|
{ShutOutRestartSettings.GracePath} = 2500ms
|
||||||
|
{ShutOutRestartSettings.ProbeIntervalPath} = 1s
|
||||||
|
{ShutOutRestartSettings.ProbeMaxIntervalPath} = 4s"));
|
||||||
|
Assert.Equal(new ShutOutRestartSettings(false, TimeSpan.FromMilliseconds(2500), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4)), set);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ public sealed class UnexpectedTerminationTests
|
|||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
|
|
||||||
lifetime.Received(1).StopApplication();
|
lifetime.Received(1).StopApplication();
|
||||||
|
// ScadaBridge#132: Program turns this flag into the non-zero SelfDownExitCode.
|
||||||
|
Assert.True(service.TerminatedOutsideHostShutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that a normal stop does not trigger an application stop.</summary>
|
/// <summary>Verifies that a normal stop does not trigger an application stop.</summary>
|
||||||
@@ -84,5 +86,19 @@ public sealed class UnexpectedTerminationTests
|
|||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
|
|
||||||
lifetime.DidNotReceive().StopApplication();
|
lifetime.DidNotReceive().StopApplication();
|
||||||
|
Assert.False(service.TerminatedOutsideHostShutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132: the self-down exit code is non-zero (Windows service recovery with
|
||||||
|
/// failureflag 1 restarts only on a non-zero exit) and is only pushed into a Windows service
|
||||||
|
/// lifetime — a console / docker lifetime is left alone.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SelfDownExitCode_IsNonZero_AndOnlyAppliedToAServiceLifetime()
|
||||||
|
{
|
||||||
|
Assert.NotEqual(0, AkkaHostedService.SelfDownExitCode);
|
||||||
|
Assert.False(AkkaHostedService.MarkServiceSelfDownExit(null));
|
||||||
|
Assert.False(AkkaHostedService.MarkServiceSelfDownExit(Substitute.For<IHostLifetime>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.Configuration;
|
||||||
|
using Akka.Remote;
|
||||||
|
using Akka.Remote.Transport;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#132, on real in-process two-node clusters built from the production
|
||||||
|
/// <c>BuildHocon</c> (same harness as <see cref="LeaderAutoDownScenarioTests"/>, with Akka's
|
||||||
|
/// throttler transport to cut and restore the network without killing either node):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>the FROZEN-node shape — only one side downed the other before the network came back —
|
||||||
|
/// ends with the shut-out node restarting (its <c>ActorSystem</c> terminates) and the other
|
||||||
|
/// staying Up;</item>
|
||||||
|
/// <item>a partition held past the window and then HEALED — each side downed the other — ends
|
||||||
|
/// with exactly one restart, the higher address; a fresh incarnation on that address rejoins the
|
||||||
|
/// survivor as a 2-member cluster, and the survivor is still Up after its grace;</item>
|
||||||
|
/// <item>with the switch OFF, the healed partition keeps both sides up (the #116 gate-4 contract
|
||||||
|
/// before #132) — the positive control that the restart comes from the feature.</item>
|
||||||
|
/// </list>
|
||||||
|
/// "Restart" in-process is the termination of the node's <c>ActorSystem</c>; the Host turns that
|
||||||
|
/// into a process exit (covered by <c>UnexpectedTerminationTests</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShutOutRestartScenarioTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Heartbeat = TimeSpan.FromMilliseconds(500);
|
||||||
|
private static readonly TimeSpan Threshold = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly List<ActorSystem> _systems = new();
|
||||||
|
|
||||||
|
private ActorSystem StartNode(
|
||||||
|
int selfPort, int peerPort, TimeSpan stableAfter, bool restartWhenShutOut = true, TimeSpan? grace = null)
|
||||||
|
{
|
||||||
|
const string scheme = "akka.trttl.tcp";
|
||||||
|
var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = selfPort };
|
||||||
|
var clusterOptions = new ClusterOptions
|
||||||
|
{
|
||||||
|
SeedNodes = new List<string>
|
||||||
|
{
|
||||||
|
$"{scheme}://scadabridge@127.0.0.1:{selfPort}",
|
||||||
|
$"{scheme}://scadabridge@127.0.0.1:{peerPort}",
|
||||||
|
},
|
||||||
|
SplitBrainResolverStrategy = "leader-auto-down",
|
||||||
|
StableAfter = stableAfter,
|
||||||
|
HeartbeatInterval = Heartbeat,
|
||||||
|
FailureDetectionThreshold = Threshold,
|
||||||
|
MinNrOfMembers = 1,
|
||||||
|
LeaderAutoDown = new LeaderAutoDownOptions
|
||||||
|
{
|
||||||
|
RestartWhenShutOut = restartWhenShutOut,
|
||||||
|
ShutOutGrace = grace ?? TimeSpan.FromSeconds(20),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var hocon = AkkaHostedService.BuildHocon(
|
||||||
|
nodeOptions, clusterOptions, new[] { "Central" },
|
||||||
|
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
|
||||||
|
var overrides = "akka.coordinated-shutdown.run-by-actor-system-terminate = off\n"
|
||||||
|
+ "akka.remote.dot-netty.tcp.applied-adapters = [trttl]\n"
|
||||||
|
+ "akka.remote.retry-gate-closed-for = 500ms\n"
|
||||||
|
// Fast probes so a healed partition is noticed within the test's patience.
|
||||||
|
+ "scadabridge.cluster.leader-auto-down.shut-out-probe-interval = 1s\n"
|
||||||
|
+ "scadabridge.cluster.leader-auto-down.shut-out-probe-max-interval = 2s\n";
|
||||||
|
var config = ConfigurationFactory.ParseString(overrides)
|
||||||
|
.WithFallback(ConfigurationFactory.ParseString(hocon));
|
||||||
|
var system = ActorSystem.Create("scadabridge", config);
|
||||||
|
_systems.Add(system);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<bool> Throttle(ActorSystem from, ActorSystem to, ThrottleMode mode) =>
|
||||||
|
((RemoteActorRefProvider)((ExtendedActorSystem)from).Provider).Transport.ManagementCommand(
|
||||||
|
new SetThrottle(Akka.Cluster.Cluster.Get(to).SelfAddress, ThrottleTransportAdapter.Direction.Both, mode));
|
||||||
|
|
||||||
|
private async Task<(ActorSystem Lower, ActorSystem Higher, int LowerPort, int HigherPort)> StartPair(
|
||||||
|
TimeSpan stableLower, TimeSpan stableHigher, bool restartWhenShutOut = true)
|
||||||
|
{
|
||||||
|
var p1 = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var p2 = TwoNodeClusterFixture.GetFreeTcpPort();
|
||||||
|
var (lowerPort, higherPort) = p1 < p2 ? (p1, p2) : (p2, p1);
|
||||||
|
var lower = StartNode(lowerPort, higherPort, stableLower, restartWhenShutOut);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(lower, 1, TimeSpan.FromSeconds(30));
|
||||||
|
var higher = StartNode(higherPort, lowerPort, stableHigher, restartWhenShutOut);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(lower, 2, TimeSpan.FromSeconds(30));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(higher, 2, TimeSpan.FromSeconds(30));
|
||||||
|
return (lower, higher, lowerPort, higherPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> Terminates(ActorSystem system, TimeSpan within)
|
||||||
|
{
|
||||||
|
var done = await Task.WhenAny(system.WhenTerminated, Task.Delay(within));
|
||||||
|
return done == system.WhenTerminated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frozen-node shape: only the LOWER node downs the higher one (the higher one's window is far
|
||||||
|
/// longer than the cut), then the network returns. The higher node — still counting the lower as
|
||||||
|
/// a member — is refused, restarts without a tiebreak; the lower node stays Up. Then the same with
|
||||||
|
/// roles swapped, so the single-side rule is proven independent of the address order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="shutOutIsHigher">Whether the shut-out (frozen) node has the higher address.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task SingleSideShutOut_ShutOutNodeRestarts_OtherStaysUp(bool shutOutIsHigher)
|
||||||
|
{
|
||||||
|
var quick = TimeSpan.FromSeconds(2);
|
||||||
|
var never = TimeSpan.FromMinutes(5);
|
||||||
|
var (lower, higher, _, _) = shutOutIsHigher
|
||||||
|
? await StartPair(quick, never)
|
||||||
|
: await StartPair(never, quick);
|
||||||
|
var (survivor, shutOut) = shutOutIsHigher ? (lower, higher) : (higher, lower);
|
||||||
|
|
||||||
|
Assert.True(await Throttle(lower, higher, Blackhole.Instance));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(survivor, Akka.Cluster.Cluster.Get(shutOut).SelfAddress, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.Contains(Akka.Cluster.Cluster.Get(shutOut).State.Members,
|
||||||
|
m => m.Address == Akka.Cluster.Cluster.Get(survivor).SelfAddress); // it still counts the survivor
|
||||||
|
Assert.True(await Throttle(lower, higher, Unthrottled.Instance));
|
||||||
|
|
||||||
|
Assert.True(await Terminates(shutOut, TimeSpan.FromSeconds(30)),
|
||||||
|
"the shut-out node kept running as a second active node");
|
||||||
|
Assert.False(await Terminates(survivor, TimeSpan.FromSeconds(5)), "the survivor restarted too");
|
||||||
|
Assert.Equal(MemberStatus.Up, Akka.Cluster.Cluster.Get(survivor).SelfMember.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A partition past the window, then healed: both sides downed and removed each other, so both
|
||||||
|
/// are refused — exactly ONE restarts, the higher address. A fresh incarnation on the higher
|
||||||
|
/// port (what the service supervisor starts) rejoins the survivor, and the survivor is still Up
|
||||||
|
/// after its grace has run out.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task HealedPartition_BothShutOut_OnlyTheHigherAddressRestarts_AndRejoins()
|
||||||
|
{
|
||||||
|
var stable = TimeSpan.FromSeconds(2);
|
||||||
|
var (lower, higher, lowerPort, higherPort) = await StartPair(stable, stable);
|
||||||
|
var lowerAddress = Akka.Cluster.Cluster.Get(lower).SelfAddress;
|
||||||
|
var higherAddress = Akka.Cluster.Cluster.Get(higher).SelfAddress;
|
||||||
|
|
||||||
|
Assert.True(await Throttle(lower, higher, Blackhole.Instance));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(lower, higherAddress, TimeSpan.FromSeconds(30));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(higher, lowerAddress, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.False(lower.WhenTerminated.IsCompleted || higher.WhenTerminated.IsCompleted,
|
||||||
|
"a node restarted while the partition was still in place");
|
||||||
|
Assert.True(await Throttle(lower, higher, Unthrottled.Instance));
|
||||||
|
|
||||||
|
Assert.True(await Terminates(higher, TimeSpan.FromSeconds(30)),
|
||||||
|
"the higher-address node did not restart after the partition healed");
|
||||||
|
Assert.False(lower.WhenTerminated.IsCompleted, "the lower-address node restarted as well");
|
||||||
|
|
||||||
|
// The supervisor's restart: a fresh incarnation on the same address rejoins the survivor.
|
||||||
|
var reborn = StartNode(higherPort, lowerPort, stable);
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(lower, 2, TimeSpan.FromSeconds(60));
|
||||||
|
await TwoNodeClusterFixture.WaitForMembersUp(reborn, 2, TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
|
// Past the survivor's 20s grace: it saw the peer rejoin, so it stays.
|
||||||
|
Assert.False(await Terminates(lower, TimeSpan.FromSeconds(25)), "the survivor restarted after its peer rejoined");
|
||||||
|
Assert.Equal(2, Akka.Cluster.Cluster.Get(lower).State.Members.Count(m => m.Status == MemberStatus.Up));
|
||||||
|
Assert.Empty(Akka.Cluster.Cluster.Get(lower).State.Unreachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switch OFF: the same healed partition keeps BOTH sides running (the pre-#132 dual-active)
|
||||||
|
/// — the positive control that the restarts above come from the feature.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task HealedPartition_SwitchOff_BothStayUp()
|
||||||
|
{
|
||||||
|
var stable = TimeSpan.FromSeconds(2);
|
||||||
|
var (lower, higher, _, _) = await StartPair(stable, stable, restartWhenShutOut: false);
|
||||||
|
var lowerAddress = Akka.Cluster.Cluster.Get(lower).SelfAddress;
|
||||||
|
var higherAddress = Akka.Cluster.Cluster.Get(higher).SelfAddress;
|
||||||
|
|
||||||
|
Assert.True(await Throttle(lower, higher, Blackhole.Instance));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(lower, higherAddress, TimeSpan.FromSeconds(30));
|
||||||
|
await TwoNodeClusterFixture.WaitForMemberRemoved(higher, lowerAddress, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.True(await Throttle(lower, higher, Unthrottled.Instance));
|
||||||
|
|
||||||
|
Assert.False(await Terminates(higher, TimeSpan.FromSeconds(15)), "a node restarted with the switch off");
|
||||||
|
Assert.False(lower.WhenTerminated.IsCompleted, "a node restarted with the switch off");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prepares the fixture before the tests run.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Releases the resources held by this fixture asynchronously.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var s in _systems)
|
||||||
|
{
|
||||||
|
try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user