a61e041e58
Promotes the two apps' private replacements for ActiveNodeHealthCheck into one shared primitive, and retires the leader/RoleLeader selection they were written to avoid. Both Akka consumers had already hand-rolled a replacement rather than use this package (ScadaBridge OldestNodeActiveHealthCheck, OtOpcUa ClusterPrimaryHealthCheck), so the entire active-node surface here — ActiveNodeHealthCheck, AkkaActiveNodeGate — had ZERO consumers. It was not merely unused: it was avoided, twice, for the same reason. Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered. The two agree on a freshly-formed cluster, which is why single-node and happy-path tests never caught it. They diverge permanently after any restart: the restarted node rejoins as the youngest but keeps its address, so if it holds the lower address it becomes leader while the singletons — and all the work they own — stay on the other node. New ClusterActiveNode is the single implementation: oldest Up member, optional role scope, plus role-preference resolution for a fused node that must answer for the role its singletons are pinned to. ActiveNodeHealthCheck and AkkaActiveNodeGate both delegate to it, so an endpoint gate and the /health/active probe an orchestrator routes by cannot disagree. BREAKING (behaviour, not signature): - Selection is by age, not leadership. - The role-filtered mode no longer reports Healthy for a node LACKING the role. That "not applicable => Healthy" made the tier answer 200 on every node and silently broke leader-pinning (lmxopcua#494); it is now Unhealthy by default, overridable via NoActiveRoleStatus. This case is reachable, not defensive — OtOpcUa's RoleParser admits dev-only and cluster-role-only nodes. - Identity compares UniqueAddress, so a node restarted on the same host:port is correctly a different member during the overlap. Results now carry activeRole/selfAddress/activeNode in the 0.2.0 per-entry data object, so a standby reports WHO is active and a dashboard can render a pair from either node's payload alone. Tests: 45 Akka (was 39), 76 total. The age-vs-address divergence is pinned against a real two-node cluster built so the oldest member is not the lowest-addressed one, with a fixture sanity check so it cannot pass for the wrong reason.
532 lines
34 KiB
Markdown
532 lines
34 KiB
Markdown
# Akka.NET HA, Failover & Singletons — ScadaBridge + OtOpcUa Deep Dive
|
||
|
||
*Written 2026-07-22 against ScadaBridge `main @ 69b3ccfc` and OtOpcUa (`lmxopcua`) `master @ 0de1352e`.
|
||
Both trees contain the 2026-07-21 downing/election corrections (ScadaBridge `cf3bd52f`; OtOpcUa `2964361a` auto-down + `50b55ee4` oldest-driver election — note `0de1352e` itself is the adjacent NU1903 dependency pin, not the redundancy fix).*
|
||
|
||
**Design priority (stated 2026-07-22):** for the 2-node site pairs, a network-partition
|
||
split-brain is *less* risky than failing to bring the second node up quickly. HA goal:
|
||
either node can be launched first, automatic failover, no manual intervention. The
|
||
architecture below is evaluated against that priority throughout.
|
||
|
||
---
|
||
|
||
## 1. How Akka.NET handles failover — the fundamentals both apps build on
|
||
|
||
The deployment unit at every site is a **2-node Akka.NET cluster** (one node per Windows
|
||
VM). Central is also a 2-node cluster, with SQL Server; sites have no SQL Server.
|
||
Everything HA-related decomposes into five Akka mechanisms:
|
||
|
||
### 1.1 Membership and seed nodes
|
||
A cluster forms by gossip. Each node lists `seed-nodes`; a booting node sends `InitJoin`
|
||
to the seeds. **Only the FIRST seed in the list may join itself to form a *new* cluster** —
|
||
any other node waits until some existing member answers. Both apps set
|
||
`min-nr-of-members = 1`, so a single node (if it's allowed to form) goes `Up` alone and
|
||
hosts all singletons — the pair does not need both members to become operational.
|
||
|
||
### 1.2 Failure detection (the heartbeat)
|
||
Every node heartbeats its peers and feeds the intervals into a **Phi Accrual failure
|
||
detector**. When the suspicion level crosses the threshold, the peer is marked
|
||
**Unreachable** — a suspicion, not a removal. Both apps run the same values:
|
||
|
||
| Setting | Value (both apps) | Meaning |
|
||
|---|---|---|
|
||
| `failure-detector.heartbeat-interval` | **2 s** | Cluster-membership heartbeat rate |
|
||
| `failure-detector.threshold` | 10.0 | Phi threshold (suspicion sensitivity) |
|
||
| `failure-detector.acceptable-heartbeat-pause` | **10 s** | Tolerated silence before Unreachable |
|
||
| `transport-failure-detector` (remoting layer) | 2 s / 10 s pause | Detects dead TCP associations |
|
||
|
||
So a hard-killed peer is declared Unreachable roughly **10–12 s** after death. These are
|
||
exactly the "heartbeat with a small adjustable timeout" — in ScadaBridge they are bound
|
||
from config (`Cluster:HeartbeatInterval`, `Cluster:FailureDetectionThreshold` →
|
||
`ClusterOptions`, rendered into HOCON by `AkkaHostedService.BuildHocon`); in OtOpcUa they
|
||
live in `akka.conf` (lines 68–72) in the Cluster library.
|
||
|
||
### 1.3 Downing (the decision to act)
|
||
Unreachable alone changes nothing — the leader cannot make membership decisions while any
|
||
member is unreachable, singletons do not move, the cluster is wedged. Something must
|
||
**Down** the unreachable member. This is the strategy choice, and it is the whole
|
||
availability-vs-partition-safety trade:
|
||
|
||
- **Auto-downing** (`Akka.Cluster.AutoDowning` + `auto-down-unreachable-after`): after the
|
||
window, the leader *among the reachable members* downs the unreachable one. In a 1-vs-1
|
||
split, **each side downs the other** — the crash case fails over perfectly; a genuine
|
||
partition produces dual-active.
|
||
- **Split Brain Resolver `keep-oldest` (+ `down-if-alone`)**: keeps the side containing
|
||
the oldest member. Partition-safe, but **fatally broken for a 2-node pair**: in
|
||
Akka.NET 1.5.62, `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side
|
||
holding ≥ 2 members (`otherSide == 1 && thisSide >= 2`). A 1-vs-1 survivor whose peer
|
||
(the oldest) crashed takes `DownReachable` and **downs itself** — proven live:
|
||
`SBR took decision Akka.Cluster.SBR.DownReachable … including myself, [1] unreachable of [2] members`.
|
||
Net effect: the pair could not survive a crash of the oldest node — total outage.
|
||
|
||
**Both apps switched their default from `keep-oldest` to `auto-down` on 2026-07-21**
|
||
(ScadaBridge `cf3bd52f`, decision record
|
||
`ScadaBridge/docs/plans/2026-07-21-auto-down-availability-decision.md`; OtOpcUa
|
||
`2964361a`, same fix ported). The owner rationale recorded in the decision doc is
|
||
verbatim the priority stated above: *"network partitions are less of a risk than if this
|
||
stops working."*
|
||
|
||
Rejected alternatives (from the decision record): `static-quorum(1)` → `DownAll` (worse);
|
||
`static-quorum(2)` → survivor self-downs; `keep-majority` → keeps the lowest-address side,
|
||
same fatal crash re-keyed; `lease-majority` → needs a shared lease store (no SQL/K8s at
|
||
sites); a third arbiter node → no third VM at sites; a custom downing provider → would
|
||
reimplement AutoDowning.
|
||
|
||
### 1.4 Cluster singletons (what actually "fails over")
|
||
`ClusterSingletonManager` runs on every node of the (optionally role-filtered) cluster
|
||
and guarantees exactly one instance of the actor, hosted on the **oldest member** of that
|
||
role. `ClusterSingletonProxy` routes to wherever it currently lives. Failover semantics:
|
||
|
||
- **Graceful shutdown** (service stop, redeploy): hand-over protocol — the old host
|
||
transfers to the new oldest with no dead window beyond hand-over latency. Both apps
|
||
hook `CoordinatedShutdown` (`run-by-clr-shutdown-hook = on`; ScadaBridge additionally
|
||
drains each singleton via a `PhaseClusterLeave` `GracefulStop` task, 10 s per singleton,
|
||
`phases.cluster-leave.timeout = 15s`).
|
||
- **Hard crash**: the singleton is simply gone until the dead node is **Downed**; then the
|
||
new oldest member's manager instantiates a fresh instance. This is why downing is the
|
||
linchpin — no downing decision, no singleton recovery, ever.
|
||
|
||
### 1.5 Oldest-member semantics — the family-wide rule
|
||
Both codebases converged on the same insight: **Akka's "leader" (lowest address) and
|
||
"oldest member" (singleton placement) diverge permanently after any node restart** — the
|
||
restarted node rejoins as a *new incarnation*, becoming the youngest, but may still have
|
||
the lowest address and thus be leader. Any "active node" decision keyed on
|
||
leader/lowest-address can therefore point at a node that is *not* hosting the singletons.
|
||
Both apps now derive "active/primary" strictly from **oldest Up member** (ScadaBridge
|
||
`ActiveNodeEvaluator.SelfIsOldestUp`; OtOpcUa `RedundancyStateActor.SelectDriverPrimary`
|
||
with `Member.AgeOrdering`), which by construction matches singleton placement.
|
||
|
||
---
|
||
|
||
## 2. The failover timeline (and the knobs that tune it)
|
||
|
||
End-to-end failover after a hard node death, with current production values:
|
||
|
||
```
|
||
t=0 node dies (process crash / VM loss)
|
||
t≈10-12s failure detector: peer marked Unreachable ← acceptable-heartbeat-pause (10s)
|
||
t≈25-27s auto-down window expires; survivor downs peer ← auto-down-unreachable-after (15s)
|
||
t≈25-30s survivor is now oldest; singletons instantiate;
|
||
primary/active gates flip; ServiceLevel 250 moves
|
||
```
|
||
|
||
Measured, not theoretical: ScadaBridge's 2026-07-21 docker drill measured **28 s**
|
||
(active-node crash → standby takeover) and **27 s** (standby crash → removal, zero routing
|
||
blips); the in-process `FailoverTimingTests` measured 33.7 s for a full cycle.
|
||
|
||
**Tuning for faster failover** (per the availability-first priority):
|
||
|
||
| Knob | Where | Current | Effect of lowering |
|
||
|---|---|---|---|
|
||
| `acceptable-heartbeat-pause` | SB: `Cluster:FailureDetectionThreshold`; OtOpcUa: `akka.conf` | 10 s | Faster Unreachable, but < ~5 s risks false positives from GC pauses / Windows VM scheduling stalls / snapshot freezes |
|
||
| `heartbeat-interval` | SB: `Cluster:HeartbeatInterval`; OtOpcUa: `akka.conf` | 2 s | More samples → detector converges faster; cheap to lower to 1 s |
|
||
| Auto-down window | SB: `Cluster:StableAfter`; OtOpcUa: `DowningStableAfter` const (15 s) + emitted HOCON | 15 s | Direct 1:1 reduction of failover time. Every second cut is a second of dual-active risk during a *transient* blip (link flap, VM pause) — the window exists to let transients heal before the irreversible Down |
|
||
| `down-removal-margin` (OtOpcUa akka.conf) | 15 s | Delay before rebalancing after removal | Keep ≈ downing window |
|
||
|
||
Constraint pinned by an OtOpcUa test (`Downing_window_exceeds_the_acceptable_heartbeat_pause`):
|
||
the downing window **must exceed** `acceptable-heartbeat-pause`, otherwise a node can be
|
||
downed on a pause the detector was configured to tolerate. A realistic aggressive profile
|
||
for the site pairs would be ~5 s pause + ~7–8 s window ≈ **12–13 s total failover**,
|
||
at the cost of downing a peer that stalls (not dies) for > ~12 s — acceptable per the
|
||
stated priority, but worth validating against real Windows VM GC/backup/snapshot pauses
|
||
before rollout. In ScadaBridge these are plain per-deployment config values; in OtOpcUa
|
||
the 15 s window is currently a constant in `ServiceCollectionExtensions.cs` (only the
|
||
*strategy* is config-selectable), so making the window configurable would be a small
|
||
change there.
|
||
|
||
**What happens to the dead node's side:** both apps set
|
||
`run-coordinated-shutdown-when-down = on` — a node that gets Downed (e.g. it was merely
|
||
partitioned and the other side downed it) terminates its own ActorSystem. Both apps run a
|
||
termination watchdog (ScadaBridge in `AkkaHostedService`; OtOpcUa
|
||
`ActorSystemTerminationWatchdog`) that distinguishes intentional stop from self-down and
|
||
calls `StopApplication()`, so the **process exits and the service supervisor restarts it**
|
||
(Windows: `sc.exe failure … restart/5000/restart/30000/restart/60000`; Docker:
|
||
`restart: unless-stopped`). The restarted process rejoins as a fresh incarnation —
|
||
youngest member, i.e. it comes back as the **standby**, never usurping the survivor.
|
||
|
||
---
|
||
|
||
## 3. ScadaBridge
|
||
|
||
### 3.1 Cluster formation
|
||
All HOCON is generated in code — `AkkaHostedService.BuildHocon(...)`
|
||
(`src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:258–329`) from
|
||
`ClusterOptions`/`NodeOptions` (`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs`).
|
||
Key facts:
|
||
|
||
- **Seed nodes**: central docker pair → `scadabridge-central-a:8081` then
|
||
`…-central-b:8081` (both nodes list `central-a` first). Site pairs analogous on 8082.
|
||
- **`MinNrOfMembers = 1`** (validator *requires* exactly 1) — one node forms the cluster
|
||
and brings up all singletons.
|
||
- **Roles**: central node = `["Central"]`; site node = `["Site", "site-{SiteId}"]`
|
||
(`BuildRoles`, line 406). The role drives the entire composition branch in `Program.cs`.
|
||
- **Ports**: central remoting 8081; site remoting 8082, gRPC 8083, metrics 8084.
|
||
|
||
### 3.2 Downing configuration
|
||
Configurable via **`Cluster:SplitBrainResolverStrategy`**, allowed values `auto-down` |
|
||
`keep-oldest` (case-insensitive; validator `ClusterOptionsValidator`). **Default and all
|
||
16 appsettings files: `auto-down`.** The HOCON branch (`AkkaHostedService.cs:275–286`):
|
||
|
||
```csharp
|
||
var downingBlock = string.Equals(strategy, "auto-down", OrdinalIgnoreCase)
|
||
? $@"downing-provider-class = ""Akka.Cluster.AutoDowning, Akka.Cluster""
|
||
auto-down-unreachable-after = {DurationHocon(clusterOptions.StableAfter)}"
|
||
: /* SBR provider + split-brain-resolver { active-strategy, stable-after,
|
||
keep-oldest { down-if-alone } } */;
|
||
```
|
||
|
||
`StableAfter = 00:00:15` doubles as the SBR `stable-after` and the auto-down window.
|
||
`DownIfAlone` is now validated only under `keep-oldest` (inert under auto-down).
|
||
`keep-oldest` remains supported and SBR-path tests still pin it
|
||
(`SbrFailoverTests.HardCrashOfYoungerNode_SbrDownsIt_AndOldestKeepsSingleton`).
|
||
|
||
### 3.3 Cluster singletons
|
||
All registered through `SingletonRegistrar.Start(...)` (names `{name}-singleton` /
|
||
`{name}-proxy`, PoisonPill termination, optional role, per-singleton
|
||
`PhaseClusterLeave` drain task of 10 s).
|
||
|
||
**Central — 7 singletons** (role not further scoped; central nodes are all `Central`):
|
||
|
||
| Singleton | Actor | Purpose |
|
||
|---|---|---|
|
||
| `notification-outbox` | `NotificationOutboxActor` | Notification ingest/dispatch/purge |
|
||
| `audit-log-ingest` | `AuditLogIngestActor` | Central audit ingest from sites |
|
||
| `site-call-audit` | `SiteCallAuditActor` | SiteCalls upserts + central→site retry/discard relay |
|
||
| `audit-log-purge` | `AuditLogPurgeActor` | Daily partition-switch purge |
|
||
| `site-audit-reconciliation` | `SiteAuditReconciliationActor` | Per-site audit reconciliation pull |
|
||
| `kpi-history-recorder` | `KpiHistoryRecorderActor` | KPI sampling (not readiness-gated) |
|
||
| `pending-deployment-purge` | `PendingDeploymentPurgeActor` | Expired staging-row reclaim (not readiness-gated) |
|
||
|
||
`RequiredSingletonsHealthCheck` probes the first five via bounded `Identify` for
|
||
`/health/ready`; the last two are deliberately best-effort.
|
||
|
||
**Site — 2 singletons**, role-scoped to `site-{SiteId}` so each site pair elects its own:
|
||
|
||
| Singleton | Actor | Purpose |
|
||
|---|---|---|
|
||
| `deployment-manager` | `DeploymentManagerActor` | Deployment lifecycle; drains in-flight SQLite writes on hand-over |
|
||
| `event-log-handler` | `EventLogHandlerActor` | Event-log queries always served by the active node (event log is node-local, not replicated) |
|
||
|
||
Deliberately **not** singletons: `dcl-manager` (DataConnectionManagerActor) and
|
||
`CertStoreActor` run on **every** site node — the deployment singleton fans PKI trust
|
||
changes to both nodes' cert stores so a failover never finds a stale trust store.
|
||
|
||
### 3.4 Active-node gating
|
||
One definition of "active": `ActiveNodeEvaluator.SelfIsOldestUp`
|
||
(`src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ActiveNodeEvaluator.cs`) — oldest
|
||
Up member, explicitly **not** `cluster.State.Leader` (the XML docs spell out the
|
||
leader-vs-oldest divergence after restarts). Consumers:
|
||
|
||
- **Central inbound API**: `IActiveNodeGate` → `InboundApiEndpointFilter` returns
|
||
**HTTP 503 `{code:"STANDBY_NODE"}`** on the standby; Traefik routes on
|
||
`/health/active` (`OldestNodeActiveHealthCheck` + `DatabaseHealthCheck`), so the proxy
|
||
and the API agree on which node is active.
|
||
- **Site store-and-forward**: `storeAndForwardService.SetDeliveryGate(() =>
|
||
clusterNodeProvider.SelfIsPrimary)` — only the active node runs the delivery sweep;
|
||
without the gate both nodes would deliver the same replicated `sf_messages` rows
|
||
(systematic duplicate external calls). Re-evaluated per sweep tick, so delivery resumes
|
||
within one `RetryTimerInterval` after failover.
|
||
- **Heartbeat to central**: `SiteCommunicationActor` stamps `HeartbeatMessage.IsActive`
|
||
from the same evaluator (exception-safe, defaults to `false`).
|
||
|
||
Takeover is purely membership-driven: peer Downed → survivor becomes oldest →
|
||
`SelfIsOldestUp` flips → singletons, S&F gate, `/health/active`, heartbeat all follow.
|
||
|
||
### 3.5 Site state without SQL Server — LocalDb
|
||
Sites persist everything in **one consolidated SQLite file** (`LocalDb:Path`, required,
|
||
on the data volume) via `ZB.MOM.WW.LocalDb` — 10 replicated tables: `OperationTracking`,
|
||
`site_events`, `sf_messages` (the store-and-forward buffer), and 7 config tables
|
||
(deployed configurations, static overrides, shared scripts, external systems, DB
|
||
connections, DCL definitions, native alarm state).
|
||
|
||
**Pair replication** (default OFF; opt-in `LocalDb:Replication:PeerAddress` + matching
|
||
`ApiKey` both sides; fail-closed auth interceptor; rides the existing gRPC 8083 listener):
|
||
CDC triggers capture row mutations, shipped bidirectionally under HLC last-writer-wins;
|
||
snapshot resync merges per row and never deletes. **This is what makes site failover
|
||
real**: the standby already holds the S&F buffer and the full config, so on takeover it
|
||
just opens its delivery gate over data it already has — zero cross-node fetch (the old
|
||
notify-and-fetch deploy path is deleted).
|
||
|
||
Not replicated (by design): the event log (hence the `event-log-handler` singleton),
|
||
in-memory alarm state (re-evaluated from incoming values on the survivor), and
|
||
`notification_lists`/`smtp_configurations` (would ship plaintext SMTP passwords).
|
||
|
||
**Operational constraints:**
|
||
- **Stop/start a site pair TOGETHER.** Rolling upgrades are unsupported: Phase 2 deleted
|
||
the legacy replicator and its `SfBufferSnapshot` compat handler, so a mixed-version
|
||
pair runs but silently does not converge.
|
||
- A node offline longer than `TombstoneRetention` (7 d) can **resurrect deleted rows** on
|
||
rejoin; within the window rejoin is safe and self-correcting.
|
||
- `MaxBatchSize` counts **rows, not bytes** — rig pins 16 (the 500 default could build a
|
||
~35 MB batch against the 4 MB gRPC cap).
|
||
|
||
### 3.6 Verified behavior + remaining gaps
|
||
- Auto-down failover drill (docker `failover-drill.sh`, SIGKILL): active-crash takeover
|
||
28 s, standby-crash 27 s, 0 routing blips; both drill modes now *assert* recovery.
|
||
- After a real partition the two mutually-downed halves **do not merge** (quarantined
|
||
association) — an operator restarts one side, which rejoins as standby. This is the
|
||
accepted dual-active trade.
|
||
- **The old "restart central-a and central-b together (SBR self-down)" gotcha is closed**
|
||
by auto-down — but a related constraint survives with a different mechanism: the
|
||
**seed-node bootstrap constraint** (§6.1).
|
||
- ⚠ Stale doc: `docs/deployment/topology-guide.md:101` still says keep-oldest +
|
||
down-if-alone. Authoritative: `Component-ClusterInfrastructure.md`, the auto-down
|
||
decision record, `docker/README.md`.
|
||
|
||
---
|
||
|
||
## 4. OtOpcUa
|
||
|
||
### 4.1 Cluster formation
|
||
Base HOCON in the library (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf`):
|
||
system `otopcua`, remoting port 4053, `min-nr-of-members = 1`, empty seeds/roles overlaid
|
||
at runtime from `AkkaClusterOptions` (`Cluster:*` config; roles restricted to
|
||
`admin` | `driver` | `dev` by `RoleParser`). Bootstrap via Akka.Hosting:
|
||
`WithOtOpcUaClusterBootstrap` (remoting + clustering + the downing HOCON, Prepended).
|
||
Note the dual role plumbing: `OTOPCUA_ROLES` drives DI composition (`hasAdmin`/`hasDriver`
|
||
branches in `Program.cs`), while `Cluster__Roles__*` drives the actual Akka member roles —
|
||
docker-dev sets both (a past production incident came from setting only `OTOPCUA_ROLES`,
|
||
leaving the Akka member role-less).
|
||
|
||
**Current mesh shape (important):** today OtOpcUa runs **one Akka mesh for the whole
|
||
fleet** — docker-dev is six nodes (central-1/2 = `admin,driver`; site-a-1/2, site-b-1/2 =
|
||
`driver`) all seeded by `central-1`, with logical MAIN/SITE-A/SITE-B separation done by
|
||
`ServerCluster.ClusterId` rows in the shared ConfigDb, not by separate meshes. The
|
||
per-cluster mesh design (§4.4) will change this to one mesh per 2-node pair — the shape
|
||
that matches your site deployment.
|
||
|
||
### 4.2 Downing configuration
|
||
Same key as ScadaBridge: **`Cluster:SplitBrainResolverStrategy`**, default **`auto-down`**,
|
||
allowed `auto-down` | `keep-oldest`, anything else **fails the host at startup**
|
||
(`ArgumentOutOfRangeException` — no silent fallback; pinned by
|
||
`Unknown_strategy_throws_rather_than_silently_falling_back`). `BuildDowningHocon` emits:
|
||
|
||
```
|
||
akka.cluster {
|
||
downing-provider-class = "Akka.Cluster.AutoDowning, Akka.Cluster"
|
||
auto-down-unreachable-after = 15000ms
|
||
}
|
||
```
|
||
|
||
(the comment notes `auto-down-unreachable-after` defaults to `off` upstream and is
|
||
load-bearing — AutoDowning without it never downs anything). Under `keep-oldest` the
|
||
typed `KeepOldestOption { DownIfAlone = true }` installs the SBR instead; the
|
||
`split-brain-resolver` block in akka.conf is inert under the default. Tests read the
|
||
**effective config off a running ActorSystem** (not the input HOCON) — provider class,
|
||
15 s window, case-insensitivity, and the window-exceeds-heartbeat-pause invariant.
|
||
|
||
**Live gate still open:** the 1-vs-1 oldest-crash pathology cannot be exercised on the
|
||
current six-node docker-dev mesh; the crash-the-oldest drill is deferred to the
|
||
per-cluster mesh work (Phase 6/7). Supporting live evidence exists from a 2-container
|
||
kill test (`archreview/plans/artifacts/459-oldest-crash-live-finding-2026-07-15.md`) and
|
||
from ScadaBridge's identical fix. Cautionary note recorded in `docs/Redundancy.md`:
|
||
the pre-existing `HardKillFailoverTests` stayed green under broken keep-oldest because it
|
||
shut down the *transport* (ActorSystem alive) rather than killing the process — "a
|
||
standing example of a green test over a fatal defect."
|
||
|
||
### 4.3 Redundancy primary election + ServiceLevel
|
||
- **`RedundancyStateActor`** (an `admin`-role singleton) computes the fleet snapshot:
|
||
`SelectDriverPrimary` = oldest **Up** member carrying the `driver` role
|
||
(`Member.AgeOrdering`; `Leaving` excluded — a leaving node is handing its singletons
|
||
over and must not be Primary). Debounced 250 ms, republished on a 10 s heartbeat over
|
||
distributed pub/sub topic `redundancy-state` (DPS doesn't replay to late subscribers).
|
||
- **`NodeRedundancyState`** carries `RedundancyRole { Primary, Secondary, Detached }` and
|
||
`IsDriverPrimary` (renamed from `IsRoleLeaderForDriver` — "the name now describes what
|
||
the value means rather than how it used to be computed").
|
||
- **ServiceLevel** (`ServiceLevelCalculator`): health basis 240 (healthy) / 200 (stale) /
|
||
100 (critically degraded) / 0 (down), **+10 if driver Primary**, so a healthy pair
|
||
reads **250 / 240**. Published into the OPC UA `Server.ServiceLevel` variable via
|
||
`SdkServiceLevelPublisher` (deferred publisher until the SDK server exists; first
|
||
computed value always published so no node lingers at the SDK default 255).
|
||
- **Client-visible failover is OPC UA non-transparent redundancy**: each node has its own
|
||
`ApplicationUri`; clients read `Server.ServerArray` (self + peers, from
|
||
`PeerApplicationUris`) and pick the endpoint with the highest ServiceLevel. The shipped
|
||
client (`OpcUaClientService`) does this: failover URL list, KeepAlive-failure trigger,
|
||
single-flight failover guard, data + alarm subscription replay after reconnect.
|
||
**Consumer note:** because Primary = oldest (not lowest address), after any node
|
||
restart a ServiceLevel-selecting client may legitimately prefer a different node than
|
||
before — that's correct behavior, not a bug.
|
||
|
||
**Primary gating of the data plane (`PrimaryGatePolicy`)** — the S4 default-deny fix:
|
||
|
||
```csharp
|
||
localRole switch {
|
||
Primary => true,
|
||
Secondary or Detached => false,
|
||
_ /* unknown */ => driverMemberCount <= 1, // multi-driver: DENY until proven
|
||
};
|
||
```
|
||
|
||
During the boot window (role not yet known) a multi-driver node **rejects device writes**
|
||
with `not primary (role unknown)` (denial meter `reason=role-unknown`); a single-driver
|
||
node stays default-allow. Gated surfaces: device writes, native-alarm acks, alarm-alert
|
||
fan-out. A deliberately *different* policy governs alarm-history drain: unknown → drain
|
||
anyway ("a duplicate row beats a silent gap"), keyed on whether the Primary shares this
|
||
node's queue (pair-local). Live-proven on a 2-node docker rig (R2-04 T15): ServiceLevel
|
||
250/240, boot-window write rejected, steady-state secondary rejected + reverted, primary
|
||
write reaches the device.
|
||
|
||
Failover sequence when the primary dies: peer downed (auto-down) → survivor is oldest
|
||
driver → `SelectDriverPrimary` flips → snapshot republished → gates open on the survivor →
|
||
ServiceLevel 250 moves → OPC UA clients see the old endpoint die (KeepAlive) and re-select
|
||
by ServiceLevel. Singletons migrate to the same node the election names, because both
|
||
derive from member age.
|
||
|
||
### 4.4 Singletons and the per-cluster mesh gap
|
||
Five control-plane singletons, all role-pinned to `admin`:
|
||
|
||
| Singleton | Actor | Purpose |
|
||
|---|---|---|
|
||
| `config-publish` | `ConfigPublishCoordinator` | Persists per-node deploy ACKs |
|
||
| `admin-operations` | `AdminOperationsActor` | Operator ack/shelve; TagConfig deploy gate |
|
||
| `audit-writer` | `AuditWriterActor` | Audit persistence |
|
||
| `fleet-status` | `FleetStatusBroadcaster` | AdminUI fleet panel |
|
||
| `redundancy-state` | `RedundancyStateActor` | Primary election + DPS publisher |
|
||
|
||
**KNOWN LIMITATION:** the Primary election is scoped **per Akka cluster, not per
|
||
application Cluster**. On the one-mesh fleet, `SelectDriverPrimary` names exactly ONE
|
||
Primary among *all* driver nodes (MAIN + SITE-A + SITE-B), while every gated resource is
|
||
pair-local. The per-cluster mesh design
|
||
(`OtOpcUa/docs/plans/2026-07-21-per-cluster-mesh-design.md`) fixes this by construction:
|
||
one Akka mesh per application Cluster (two nodes max), central↔cluster joined by explicit
|
||
transports (modeled on ScadaBridge's ClusterClient/gRPC/HTTP trio), so "am I the driver
|
||
Primary?" and "the resource I'm gating" have the same scope. Status: Phases 0a (auto-down)
|
||
and 0b (oldest-Up election) DONE and merged; Phases 1–7 (ClusterNode columns, comm actors,
|
||
config fetch-and-cache, cut driver-side ConfigDb, gRPC stream contract, mesh partition,
|
||
failover drill/live gate) not started — **but now sequenced by a program plan
|
||
(`OtOpcUa/docs/plans/2026-07-22-per-cluster-mesh-program.md`, 2026-07-22), driven by the
|
||
decision that OtOpcUa pairs will run on the SAME Windows VMs as the ScadaBridge nodes** —
|
||
two independent, identically-postured 2-node clusters per site, and driver nodes off the
|
||
ConfigDb entirely (sites have no SQL Server).
|
||
|
||
### 4.5 Surviving central-SQL loss — availability guarantees
|
||
Two mechanisms make a driver pair independent of central SQL Server availability:
|
||
|
||
- **Address-space availability guarantee (#485/#486):** an artifact the node cannot read
|
||
is *no answer*, never *an empty configuration*. Previously a transient ConfigDb error
|
||
mid-deploy parsed zero bytes into an empty composition → the planner diffed it as a
|
||
`PureRemove` of every node → served address space emptied, drivers stopped,
|
||
subscriptions cleared, deploy still reported Applied. Now `OpcUaPublishActor` /
|
||
`DriverHostActor` hold last-known-good address space, drivers, and subscriptions, and
|
||
report the deploy **Failed** without advancing the revision so the retry lands.
|
||
- **LocalDb Phase 1 boot-from-cache:** every driver node caches the chunked, SHA-256
|
||
verified deployed-configuration artifact in a pair-local SQLite DB; a node restarting
|
||
into a central-SQL outage boots from the last artifact it applied. With opt-in pair
|
||
replication (`deployment_artifacts` + `deployment_pointer`, HLC last-writer-wins,
|
||
fail-closed auth) either node holds a byte-identical copy even if it never applied that
|
||
deploy itself. Scope: config artifact only — no live tags/alarms/historian data.
|
||
|
||
---
|
||
|
||
## 5. Dual-active: what it concretely means here
|
||
|
||
Under auto-down, a *genuine* partition (both nodes alive, link cut) ends with each side
|
||
downing the other and running active until an operator restarts one side (the mutual
|
||
downing quarantines the association — the sides will not self-merge). Accepted per the
|
||
availability-first decision; concrete blast radius:
|
||
|
||
**ScadaBridge site pair:** both nodes' `SelfIsOldestUp` = true → both open the S&F
|
||
delivery gate → **duplicate external deliveries** of the same replicated `sf_messages`
|
||
rows for the partition's duration; both stamp `IsActive` heartbeats. LocalDb replication
|
||
itself is partition-tolerant (HLC last-writer-wins reconverges on heal, after the
|
||
operator restart). Central pair: both serve `/health/active` = 200 and run duplicate
|
||
singleton sets against the same SQL Server — duplicated sweeps/purges (mostly idempotent
|
||
upserts), and Traefik may alternate between two "active" backends.
|
||
|
||
**OtOpcUa pair:** both nodes elect themselves driver Primary → both publish
|
||
**ServiceLevel 250** → clients on either side keep their session; both sides accept
|
||
device writes → **two masters against the same field devices** for the duration. This is
|
||
the classic OT dual-primary risk — mitigated by rarity (2 VMs, one LAN segment, real
|
||
partitions ≈ switch failure) and by the alternative (keep-oldest) having been proven to
|
||
turn a plain crash into a total outage.
|
||
|
||
Detection/ops: the survivor logs the downing decision; OtOpcUa emits
|
||
`otopcua.redundancy.service_level_change` and `primary_gate_denied` meters; two
|
||
simultaneous 250s / two 200-responses on `/health/active` are the alarm signal. Recovery
|
||
is always "restart one side; it rejoins as youngest/standby."
|
||
|
||
---
|
||
|
||
## 6. Gaps vs the stated HA goal ("either node first, automatic failover")
|
||
|
||
### 6.1 The seed-node bootstrap constraint — CLOSED 2026-07-22 (differently per repo)
|
||
**Automatic failover: achieved.** Either node crashing is survivable, unattended, in
|
||
~27–30 s. The remaining gap — a non-first seed cold-starting alone loops on `InitJoin`
|
||
forever (the "registered outage gap") — was **closed in both repos on 2026-07-22**, but
|
||
execution overturned the original design and the two repos legitimately diverged:
|
||
|
||
**⚠️ The original design's safety claim was FALSE — proven independently in both repos.**
|
||
The planned `SelfFormAfter` watchdog assumed "window expires mid-join-handshake → benign,
|
||
Akka ignores `Join` once joined." Wrong: the watchdog sits *outside* Akka's join
|
||
handshake and cannot distinguish *no seed answered* from *a seed answered and the join is
|
||
in flight*. On a routine restart into a live peer, the join stalls briefly behind removal
|
||
of the node's own stale incarnation; a `Join(self)` fired in that window **abandons the
|
||
in-flight join and forms a second cluster at the same address — a permanent island**.
|
||
ScadaBridge proved it with a written test (split still unhealed after 90 s); OtOpcUa hit
|
||
it live when its manual-failover drill bounced a node (InitJoinAck received, no Welcome
|
||
inside the window, fallback fired → second cluster).
|
||
|
||
**ScadaBridge's fix — self-first seed ordering (no runtime code).** Every node lists
|
||
ITSELF as `seed-nodes[0]`, partner second. Akka's own `FirstSeedNodeProcess` then
|
||
implements exactly the intended semantics *inside* the handshake (InitJoin the other
|
||
seeds; self-join only if nobody answers) — race-free by construction. A `StartupValidator`
|
||
rule hard-gates the ordering at boot. Live-proven behaviors: lone cold-start self-joins
|
||
after ~5 s (`seed-node-timeout`); restart into a live peer rejoins (never islands);
|
||
simultaneous reachable cold-start converges to ONE cluster (the old "two clusters that
|
||
never merge" objection was disproved by test); a genuine boot partition dual-forms — the
|
||
accepted auto-down trade. There is **no `SelfFormAfter` option in ScadaBridge** — the
|
||
whole watchdog was rejected in code review.
|
||
|
||
**OtOpcUa's fix — converged on self-first ordering the same day.** OtOpcUa first shipped
|
||
the watchdog and hit the race live (its manual-failover drill bounced a node; the fallback
|
||
islanded it), patched it with a TCP reachability guard — and then **retired the whole
|
||
watchdog hours later** (`lmxopcua master @ 3f24d4d6`, a breaking change):
|
||
`ClusterBootstrapFallback` and `Cluster:SelfFormAfter` are **deleted**, docker-dev
|
||
`central-2` now lists itself as `SeedNodes__0`, and a new `AkkaClusterOptionsValidator`
|
||
enforces the ordering at boot. Two subtleties its rule handles that ScadaBridge's didn't
|
||
need: the invariant is **conditional** (self must be `seed[0]` only IF self appears in
|
||
the list at all — site nodes seed off central only and are deliberately not seeds), and
|
||
identity compares on **`PublicHostname` + port** (the address Akka puts in `SelfAddress`),
|
||
because matching the `0.0.0.0` bind address would leave the rule silently inert on the
|
||
whole rig. The retirement was proven by a falsifiability control: the old peer-first
|
||
ordering test failed at 11 s against the watchdog — demonstrating the watchdog could
|
||
never usefully fire for the nodes that needed it.
|
||
|
||
**Result: both repos now share the identical mechanism** — self-first seed ordering,
|
||
validator-enforced, no bootstrap runtime code. The mesh program's Phase 6 convergence
|
||
item is already done; what remains there is only the per-pair seed topology itself.
|
||
|
||
**Ops action (outstanding):** the gitignored `ScadaBridge/deploy/wonder-app-vd03/`
|
||
overlay must have its `SeedNodes` reordered self-first before its next deploy — the new
|
||
validator rule is a deliberate hard boot gate.
|
||
|
||
### 6.2 Other open items
|
||
- **OtOpcUa auto-down live gate** still open (needs a real 1-vs-1 rig; deferred to
|
||
per-cluster mesh Phase 6/7). ScadaBridge's identical mechanism *is* drill-proven.
|
||
- **OtOpcUa election scope** is cluster-wide until per-cluster mesh Phases 1–7 land
|
||
(§4.4) — only relevant while multiple pairs share one mesh.
|
||
- **OtOpcUa's 15 s downing window is a code constant**, not config — worth making
|
||
tunable alongside any heartbeat-timeout reduction (ScadaBridge's already is:
|
||
`Cluster:StableAfter`).
|
||
- **ScadaBridge site pairs: stop/start together** for upgrades (no mixed-version LocalDb
|
||
replication); ≥ 7-day-offline nodes risk tombstone resurrection.
|
||
- **Stale doc**: `ScadaBridge/docs/deployment/topology-guide.md:101` still describes
|
||
keep-oldest.
|
||
|
||
---
|
||
|
||
## 7. Quick reference — file map
|
||
|
||
| Concern | ScadaBridge | OtOpcUa |
|
||
|---|---|---|
|
||
| HOCON / downing emit | `Host/Actors/AkkaHostedService.cs:258–329` | `Cluster/ServiceCollectionExtensions.cs:119–146` + `Cluster/Resources/akka.conf` |
|
||
| Options + validation | `ClusterInfrastructure/ClusterOptions.cs`, `ClusterOptionsValidator.cs` | `Cluster/AkkaClusterOptions.cs`, `RoleParser.cs` |
|
||
| Strategy key | `Cluster:SplitBrainResolverStrategy` (default `auto-down`) | same key, same default; unknown value = startup failure |
|
||
| Active/primary definition | `Communication/ClusterState/ActiveNodeEvaluator.cs` (oldest Up) | `ControlPlane/Redundancy/RedundancyStateActor.cs` (oldest Up `driver`) |
|
||
| Data-plane gating | `InboundApiEndpointFilter` (503 STANDBY_NODE), S&F `SetDeliveryGate` | `Runtime/Drivers/PrimaryGatePolicy.cs` + `DriverHostActor` gates |
|
||
| Singleton registration | `Host/Actors/SingletonRegistrar.cs` (7 central + 2 site) | `ControlPlane/ServiceCollectionExtensions.cs:44–94` (5, role `admin`) |
|
||
| Client-visible failover | Traefik on `/health/active`; gRPC A/B flip | OPC UA ServiceLevel 250/240 + `Server.ServerArray`; client KeepAlive failover |
|
||
| Self-down recovery | watchdog in `AkkaHostedService` → process exit → supervisor | `Host/ActorSystemTerminationWatchdog.cs` → same |
|
||
| Decision records | `docs/plans/2026-07-21-auto-down-availability-decision.md`, `docs/requirements/Component-ClusterInfrastructure.md` | `docs/Redundancy.md` §"Split-brain / downing", `docs/plans/2026-07-21-per-cluster-mesh-design.md` |
|
||
| Failover drills / tests | `docker/failover-drill.sh` (28 s/27 s), `SbrFailoverTests`, `FailoverTimingTests` | `SplitBrainResolverActivationTests`, `RedundancyPrimaryElectionTests`, R2-04 T13/T15 live gate |
|