docs/Redundancy.md's bootstrap section is rewritten around the mechanism that is
actually in force: which of Akka's two bootstrap processes runs is decided by
whether seed-nodes[0] is this node's own address, so the ORDER of Cluster:SeedNodes
is the fix, not a timer. Adds the per-process table, the shipped self-first
example, why the validator is conditional (site nodes are not seeds) and why it
matches PublicHostname rather than the 0.0.0.0 bind address.
The retirement is documented rather than erased: a new subsection explains that a
watchdog outside the join handshake cannot tell "no seed answered" from "a seed
answered and the join is in flight", and the ea45ace1 live-gate finding is kept
verbatim as the evidence that produced that conclusion (InitJoinAck at 10:49:05,
old incarnation retired 10:49:06, watchdog fired 10:49:15, second cluster). The
watchdog's own earlier PASS is kept too — it did pass what it was gated on.
Also: a live gate for the NEW mechanism is registered as outstanding (not
re-drilled on the docker-dev rig since the swap), and the plan doc that shipped
the watchdog gets a superseded banner rather than an edit, so the execution record
and its Task 8 finding stay readable.
Ripple: CLAUDE.md cluster section, docs/Configuration.md + docs/v2/Cluster.md +
docs/ServiceHosting.md SeedNodes entries, the per-cluster-mesh design doc's two
"CLOSED by SelfFormAfter" notes, and mesh-program Phase 6 (which had this
convergence queued — now marked done early) + Phase 7's live-gate name.
41 KiB
Redundancy (v2)
Overview
OtOpcUa supports OPC UA non-transparent warm/hot redundancy. Two or more OtOpcUa.Host processes run side-by-side, share the same Config DB, and join the same Akka.NET cluster. Each process owns a distinct ApplicationUri; OPC UA clients discover both endpoints by reading Server.ServerArray (NodeId i=2254) on either node and pick one based on the ServiceLevel byte that each server publishes.
Discovery surface. The
ServerArraypath on theServerobject is what each node populates with self + peerApplicationUris — seeOpcUaApplicationHost.PopulateServerArrayand the per-nodePeerApplicationUrisoption below. The redundancy-object-typeServerUriArrayproper (a child ofServer.ServerRedundancy) remains deferred pending an SDK object-type upgrade; clients should readServer.ServerArrayfor peer discovery today.
v2 change. v1's operator-managed
ClusterNode.RedundancyRolecolumn +RedundancyCoordinator/ApplyLeaseRegistry/PeerHttpProbeLoopare gone. Primary/secondary is now derived from Akka cluster membership — the oldest Up member carrying thedriverrole. The operator no longer writes a role into the DB; cluster topology drives ServiceLevel automatically.
The runtime pieces live in:
| Component | Project | Role |
|---|---|---|
RedundancyStateActor |
OtOpcUa.ControlPlane.Redundancy |
Admin-role cluster singleton; subscribes to cluster topology events, debounces 250ms, broadcasts RedundancyStateChanged on the redundancy-state DPS topic. |
OpcUaPublishActor |
OtOpcUa.Runtime.OpcUa |
Per-driver-node; subscribes to the redundancy-state topic, computes a health-aware ServiceLevel byte via ServiceLevelCalculator (see below), and forwards it to IServiceLevelPublisher. |
IServiceLevelPublisher / SdkServiceLevelPublisher |
OtOpcUa.Commons.OpcUa / OtOpcUa.OpcUaServer |
Writes the byte into the SDK's Server.ServiceLevel Variable. Production binds DeferredServiceLevelPublisher, which swaps in the real SdkServiceLevelPublisher once the SDK is up (it needs IServerInternal, available only after StandardServer.Start); until then writes route through NullServiceLevelPublisher. |
ServiceLevelCalculator |
OtOpcUa.Cluster.Redundancy (Core.Cluster) |
Pure function (NodeHealthInputs) → byte — the DB/probe-aware tiering (see truth table below). Covered by ServiceLevelCalculatorTests. Now the live publish path — OpcUaPublishActor calls it on every HealthTick and RedundancyStateChanged event. Moved to Core.Cluster so Runtime can reach it without a Runtime→ControlPlane reference. |
DbHealthProbeActor |
OtOpcUa.Runtime.Health |
Per-node; runs SELECT 1 against ConfigDb every 5s. Read by the health endpoint AND by OpcUaPublishActor (the DbReachable ServiceLevel input). |
PeerProbeSupervisor |
OtOpcUa.Runtime.Health |
Per-node; subscribes to the redundancy-state topic and maintains one PeerOpcUaProbeActor child per OTHER driver-role peer (spawn on join, stop on departure), so every node is continuously probed by its peers. |
PeerOpcUaProbeActor |
OtOpcUa.Runtime.Health |
Spawned by PeerProbeSupervisor; pings a peer opc.tcp://peer:4840 with a TCP connect (2s timeout) and publishes OpcUaProbeResult on the redundancy-state topic. A full secure-channel Hello handshake is a possible future upgrade; the TCP connect is the current real probe. |
ClusterRoleInfo |
OtOpcUa.Cluster |
Live view of cluster membership + role-leader; exposes IClusterRoleInfo to the rest of the host. (Akka's role-leader is not what elects the redundancy Primary — see below.) |
ServiceLevel tiers
Health-aware tiering (ServiceLevelCalculator — live path)
ServiceLevelCalculator.Compute(NodeHealthInputs) is the live publish path.
OpcUaPublishActor calls it on every HealthTick (~5 s) and on each
RedundancyStateChanged snapshot, then forwards the result through
IServiceLevelPublisher to the SDK's Server.ServiceLevel Variable.
The four inputs are sourced locally per driver node:
| Input | Source |
|---|---|
MemberState |
Local SelfMember.Status from the Akka cluster (Up / Joining / Leaving / …). |
DbReachable |
Local DbHealthProbeActor — OpcUaPublishActor Asks it on each HealthTick; an Ask timeout is treated as Reachable=false. |
OpcUaProbeOk |
Result of a peer probing THIS node's OPC UA endpoint: PeerProbeSupervisor spawns one PeerOpcUaProbeActor per OTHER driver-role peer; each probe publishes OpcUaProbeResult(probed-node, ok) on the redundancy-state topic; the publish actor consumes only results whose target is itself. Freshness-debounced: absent or stale (>30 s) → true (benefit of the doubt — single-node clusters and a departed peer never demote); only an actively-observed RECENT false demotes. |
Stale (derived) |
!DbReachable || (now − lastDbHealth.AsOfUtc) > 30 s || (now − snapshotEntry.AsOfUtc) > 30 s. |
IsDriverPrimary |
The local node's entry in the RedundancyStateChanged snapshot from RedundancyStateActor. |
The resulting truth table (all tiers are now reachable at runtime):
| Tier | Byte | Condition |
|---|---|---|
| Down / Detached | 0 | Member status is not Up or Joining (leaving, removed, exiting), OR node has no driver role (Detached). Published immediately — a starting or detached node never leaves the SDK default 255. |
| Critically degraded | 100 | ConfigDb unreachable AND data is stale. |
| Stale | 200 | Data stale but ConfigDb reachable. |
| Healthy follower | 240 | DB reachable + OPC UA probe ok + not stale + not the driver Primary. |
| Healthy primary | 250 | Same as healthy follower + this node is the driver Primary (+10 bonus). |
Secondary 100 → 240 (behavior change). Previously a healthy Secondary published 100 (coarse role-only mapping). It now publishes 240 — both nodes sit at 240/250 under healthy conditions, with the leader still preferred by the +10 bonus. Clients with the standard "pick highest ServiceLevel" heuristic continue to prefer the primary.
Backward-compatible fallback (legacy seam)
A node with no DbHealthStatus wired (e.g. early bootstrap window before the
first DbHealthProbeActor reply) falls back to the old role-only mapping:
Primary-leader → 240, Primary → 200, Secondary → 100, Detached → 0. Once the
first DbHealthStatus arrives the calculator takes over. The first computed
ServiceLevel (even 0) is always published so no node lingers at the SDK default
255.
Roles come from RedundancyStateActor.BuildSnapshot: a node with the driver
role is Primary when it is the oldest Up member carrying the driver role, otherwise
Secondary; a node without the driver role is Detached.
Oldest, not role leader (changed 2026-07-21). The election previously used
ClusterState.RoleLeader("driver") — the lowest-addressed Up member with the role. Akka offers
both, and they name different nodes: role leader is ordered by address (host, then port), oldest by
up-number. They agree on a freshly-formed cluster, and diverge after any restart — a restarted node
re-joins as the youngest while keeping its address, so if it holds the lower address it becomes
role leader. Meanwhile ClusterSingletonManager places singletons on the oldest, so the old
derivation could name a Primary that was not hosting the singletons, enabling the Primary-gated data
plane (writes, alarm acks, the alerts emit, the alarm-history drain) on the wrong node. Pinned by
RedundancyPrimaryElectionTests, which forms a real two-node cluster where the oldest node holds the
higher address so the two orderings genuinely disagree. NodeRedundancyState.IsRoleLeaderForDriver
was renamed IsDriverPrimary to stop the name asserting the old derivation.
KNOWN LIMITATION — the election is per Akka cluster, not per application
Cluster. The election yields exactly one Primary across the whole Akka cluster. A fleet that runs several application clusters (each with its own redundant pair) inside one Akka cluster — which is whatdocker-dev/docker-compose.ymlmodels, with MAIN, SITE-A and SITE-B — therefore has one Primary among all its driver nodes, not one per pair. Every consumer of the role inherits this:ServiceLevel, thealertsemit gate, and the inbound device-write gate below.The unit of redundancy everywhere else in the product is the application
Cluster(ClusterId) — it owns drivers, devices andClusterNoderows, and the LocalDb deployment-artifact cache is already keyed by it so that a pair shares one entry. Scoping the election the same way is the fix;RedundancyStateActoris an admin-role singleton in the same assembly asAdminOperationsActor, which already reads and groupsClusterNodesby cluster.Surfaced by the LocalDb Phase 2 live gate, where it stopped the alarm-history drain on every node in the fleet — see
docs/plans/2026-07-20-localdb-phase2-live-gate.md. The alarm drain no longer depends on this (it defers only to a node that shares its queue), but the other three gates still do.
Data flow
Cluster topology event ──────────────────────────────────────────┐
▼
RedundancyStateActor (admin singleton)
│ debounce 250ms
▼
DPS topic "redundancy-state"
│ ▲
┌───────────────────────┘ │
│ │
▼ │
Driver node: OpcUaPublishActor │
┌─────────────────────────────────────────────────────────┐ │
│ Inputs collected per ~5s HealthTick: │ │
│ • MemberState ← Akka SelfMember.Status │ │
│ • DbReachable ← DbHealthProbeActor (Ask, timeout→F) │ │
│ • OpcUaProbeOk ← OpcUaProbeResult about THIS node │──────┘
│ • Stale ← derived from above timestamps │ PeerProbeSupervisor
│ • IsLeader ← RedundancyStateChanged snapshot │ → PeerOpcUaProbeActor(s)
│ │ publish OpcUaProbeResult
│ ServiceLevelCalculator.Compute(NodeHealthInputs) │ on "redundancy-state"
│ → byte (0/100/200/240/250) │
└───────────────────────────────────────────────────────-─┘
│
▼
IServiceLevelPublisher (SdkServiceLevelPublisher)
│
▼
OPC UA Server.ServiceLevel Variable
Both DbHealthProbeActor and PeerOpcUaProbeActor feed the live publish path.
The peer probe publishes OpcUaProbeResult on the redundancy-state topic;
OpcUaPublishActor consumes only results whose target is itself and applies
freshness-debouncing before passing them to the calculator. DbHealthProbeActor
is queried directly via Ask on each HealthTick.
The admin singleton is the cluster's only RedundancyStateActor. If the admin leader fails over, the new admin node spins up its replacement, re-subscribes to cluster events, and publishes a fresh snapshot from the current Cluster.State. There is no DB-persisted state to recover.
Configuration
Per-node identity comes from appsettings.json + the OTOPCUA_ROLES env var:
{
"Cluster": {
"Hostname": "0.0.0.0",
"Port": 4053,
"PublicHostname": "node-a.lan",
// Self FIRST, partner second — see "Bootstrap: self-first seed ordering".
// node-b.lan's own config lists node-b.lan first and node-a.lan second.
"SeedNodes": [
"akka.tcp://otopcua@node-a.lan:4053",
"akka.tcp://otopcua@node-b.lan:4053"
],
"Roles": ["admin", "driver"]
}
}
OTOPCUA_ROLES=admin,driver
Both nodes share the same ConfigDb connection string; Cluster.PublicHostname + Roles are what makes them distinct in cluster gossip. List both peers in SeedNodes on both nodes so either can cold-start alone — and list each node ITSELF first: the order decides which Akka bootstrap process runs, and a node that lists its partner first can never form the cluster while that partner is down. See Bootstrap: self-first seed ordering.
There is no longer a Node:NodeId setting and no ClusterNode.RedundancyRole column (the V2 migration dropped it — primary/secondary is now derived from cluster membership age). NodeId is derived as host:port of the cluster PublicHostname (see ClusterRoleInfo.LocalNode for the formula).
RedundancyStateActorNodeId consistency (fixed).RedundancyStateActornow keys each node'sNodeRedundancyStateentry by the canonicalhost:portnode id (via aToNodeId(Address)helper mirroringClusterRoleInfo.ToNodeId). Previously it keyed bymember.Address.Host(host-only, e.g.central-2); since every subscriber matches by the canonicalhost:portform, the mismatch silently meant no node ever matched its own entry — all nodes stayed at the default ServiceLevel 255 and never learned their role. This fix makesRedundancyStateActorconsistent with the stated contract above. Additionally,RedundancyStateActornow re-publishes the current snapshot on a periodic heartbeat (default 10 s) so any node that subscribes after the last topology-change publish converges within the interval (DistributedPubSub does not replay to late subscribers).
The ClusterNode.ServiceLevelBase column still exists and is editable in the Admin UI (NodeEdit / Cluster Redundancy pages), but it no longer drives the runtime ServiceLevel — that value is computed by ServiceLevelCalculator from cluster role and live health inputs, independent of this stored preference.
Peer URI advertising
Each node advertises its partner via OpcUaApplicationHostOptions.PeerApplicationUris (an IList<string>, default empty). OpcUaApplicationHost.PopulateServerArray appends each configured peer URI to the SDK's IServerInternal.ServerUris string table after server startup, so that Server.ServerArray reads served by OnReadServerArray return both self + peers. The options bind from the OpcUa config section (see Program.cs — AddValidatedOptions<OpcUaApplicationHostOptions>(…, "OpcUa")). Set this per-node in appsettings.json:
{
"OpcUa": {
"PeerApplicationUris": ["urn:node-b:OtOpcUa"]
}
}
Node A lists Node B's ApplicationUri and vice-versa. Validated by DualEndpointTests in tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ — boots two OpcUaApplicationHost instances on loopback, asserts a real OPCFoundation client Session reading Server.ServerArray from Node A sees both URIs.
Split-brain / downing
The default downing strategy is auto-down (changed 2026-07-21). It is selected by
Cluster:SplitBrainResolverStrategy, and ServiceCollectionExtensions.BuildDowningHocon turns that
into the akka.cluster.downing-provider-class actually installed:
Cluster:SplitBrainResolverStrategy |
Provider installed | Posture |
|---|---|---|
auto-down (default) |
Akka.Cluster.AutoDowning with auto-down-unreachable-after = 15s |
Availability. The leader among the reachable members downs the unreachable peer, so a crash of either node — oldest included — fails over in place. |
keep-oldest |
Akka.Cluster.SBR.SplitBrainResolverProvider reading the split-brain-resolver block in akka.conf |
Partition-safety. A partition can never run dual-active. Not safe for a 2-node pair — see below. |
Any other value fails the host at startup rather than falling through to a default.
Why keep-oldest is no longer the default
The previous documentation in this section — and the code comments behind it — asserted that
keep-oldest with down-if-alone = on was "the correct strategy for a 2-node warm-redundancy pair."
That was wrong, and the error was not conservative: it described a total-outage configuration as the
recommended one.
In Akka.NET 1.5.62's KeepOldest.OldestDecision, the down-if-alone rescue branch requires the
surviving side to hold ≥ 2 members. In a two-node cluster a lost peer is always a 1-vs-1 split, so
the branch never fires and the lone survivor falls through to DownReachable — downing itself — and
run-coordinated-shutdown-when-down = on then terminates it. down-if-alone is a 3+-node feature; it
does not do what its name suggests here. Live-proven on the sister project's rig, whose survivor logged
SBR took decision …DownReachable and is downing [self] including myself, [1] unreachable of [2] members.
See ../../ScadaBridge/docs/plans/2026-07-21-auto-down-availability-decision.md
for the upstream decision record, which OtOpcUa follows.
The alternatives do not help a pair: static-quorum with quorum 1 hits Akka's IsTooManyMembers guard
and returns DownAll; with quorum 2 the survivor downs itself; keep-majority keeps the lowest-address
side, which just moves the fatal crash from "the oldest node" to "the other node"; lease-majority needs
a shared lease store both nodes can reach.
The accepted trade
Under auto-down, a genuine network partition (both nodes alive, link cut) leaves each side downing
the other and continuing alone: both run active, both hold singletons, and both advertise the primary
ServiceLevel. Recovery is operator-driven — after the link heals, restart one side; it rejoins as a
fresh incarnation and settles as secondary. The two sides do not merge on their own, because the mutual
downing quarantines the association.
This is a deliberate choice of availability over partition-safety, matching ScadaBridge: the pairs run one
node per VM with no external arbiter available, so a crash is a far more likely event than a partition, and
a crash under keep-oldest was unrecoverable without operator action anyway.
Deployments that would rather take an outage than ever run dual-active can set
Cluster:SplitBrainResolverStrategy: "keep-oldest" — but should read the section above first, because for a
two-node pair that choice means any crash of the oldest node is a full outage.
Recovery still depends on supervision
- A service supervisor that restarts the process on exit — production
Install-Services.ps1setssc.exe failure OtOpcUaHost … actions= restart/5000/restart/30000/restart/60000; the docker-dev rig setsrestart: unless-stopped. - The recovery watchdog
ActorSystemTerminationWatchdog(registered inProgram.csafterAddAkka) — it watchesActorSystem.WhenTerminatedand, on an unexpected self-down, callsIHostApplicationLifetime.StopApplication()so the process exits and the supervisor restarts it instead of idling forever with a dead actor system. Underauto-downthe survivor no longer needs this (it is never the one downed); the downed node still does. - Both peers listed in
SeedNodeson every node, each node listing itself first, so a restarted node can re-join via either peer and a node cold-starting while its peer is dead still forms the cluster. Without the self-first ordering it waits inInitJoinforever (auto-down removes the crash outage, not that one) — see Bootstrap: self-first seed ordering.
On
HardKillFailoverTests: that in-process test hard-kills the oldest and asserts the survivor becomes soledriverrole-leader. It passed even underkeep-oldest— because it simulates the crash withprovider.Transport.Shutdown(), which leaves node A'sActorSystemalive (a transport partition with the oldest still running), not a real process death. A real 2-containerdocker killof the oldest downed the survivor (verified 2026-07-15,archreview/plans/artifacts/459-oldest-crash-live-finding-2026-07-15.md). It is a standing example of a green test over a fatal defect: keep the distinction between a transport partition and a process death in mind when reading it.
SplitBrainResolverActivationTests pins the strategy by starting a real host through the production
bootstrap and reading akka.cluster.downing-provider-class back off the running ActorSystem — asserting
the typed option or the akka.conf text is not sufficient, because several HOCON fragments compete and the
losing one still looks correct in the file.
Failover during a partition is what the cluster and supervisor do automatically. For a planned swap — patching, draining a node — see Manual failover below.
Manual failover
The cluster redundancy page (/clusters/{id}/redundancy) carries a Live redundancy panel — the current
driver Primary and every Up driver member, read from live cluster state — and an admin-gated Trigger
failover button.
The mechanism is a graceful cluster Leave of the current Primary, never a Down:
IManualFailoverService.FailOverDriverPrimaryAsync selects the oldest Up driver member — the identical
query to RedundancyStateActor.SelectDriverPrimary, pinned together by
ManualFailoverServiceTests.Parity_with_SelectDriverPrimary so the node acted on is exactly the node the
election names. The leaving node hands its singletons over through the cluster-leave phases,
CoordinatedShutdown runs, ActorSystemTerminationWatchdog exits the process, the supervisor restarts it,
and it rejoins as the youngest member — so the survivor is now the oldest, becomes Primary, and
advertises ServiceLevel 250. OPC UA clients re-select.
| Rule | Behaviour |
|---|---|
| Authorization | AdminUiPolicies.FleetAdmin (Administrator only) — gated in markup and re-checked server-side before the call. Deliberately not ConfigEditor, which the neighbouring cluster pages use: this restarts a production node rather than editing config, and ConfigEditor also admits Designer. |
| Peer guard | Disabled, with the reason as its tooltip, when fewer than two Up driver members exist — with one node a "failover" is a shutdown. Re-evaluated inside the service against live state, so a stale page cannot bypass it. |
| Confirmation | A dialog naming the node, and what follows (restart, ServiceLevel 250 moves, clients re-select). |
| Audit | Written through the shared ZB.MOM.WW.Audit.IAuditWriter seam before the Leave — action cluster.manual-failover, actor, target. A refused failover changes nothing and writes nothing. |
Mesh-scope caveat. Until the per-cluster mesh work lands (
docs/plans/2026-07-21-per-cluster-mesh-design.md), the Primary is elected once per Akka mesh rather than per applicationClusterrow — so on a fleet running several clusters in one mesh this button acts on the whole mesh's Primary, which may belong to a different cluster than the page you are on. The panel says so. Phase 6 of the mesh program removes both the caveat and this notice.
Live gate outstanding. The auto-down change is verified by unit tests against the effective configuration, and by the sister project's live drill on an equivalent Akka version and topology. It has not yet been drilled on an OtOpcUa two-node rig — the docker-dev rig runs a single six-node mesh, where the 1-vs-1 pathology cannot occur. The drill (kill the oldest container of a genuine two-node cluster; assert the survivor stays up, takes the singletons, and reports the primary
ServiceLevel) should run alongside the per-cluster mesh work, which makes every mesh exactly two nodes. Seedocs/plans/2026-07-21-per-cluster-mesh-design.md§6.2 / Phase 0a.
Bootstrap: self-first seed ordering
Listing both peers in SeedNodes does not mean either node can cold-start alone — and neither does any
downing strategy: auto-down removes the crash outage, not this one. Akka runs a different bootstrap
process depending on whether seed-nodes[0] is the node's own address:
seed-nodes[0] is… |
Process Akka runs | Can it form a NEW cluster? |
|---|---|---|
| this node's own address | FirstSeedNodeProcess |
Yes — it InitJoins the other seeds and self-joins once seed-node-timeout (5 s) passes with nobody answering. |
| some other node | JoinSeedNodeProcess |
No — it retries InitJoin forever, however long the peer stays down. |
So the ordering is the mechanism. Every node that is a seed of its own mesh lists ITSELF first and its partner second (decision 2026-07-22):
// central-1 // central-2
"SeedNodes": [ "SeedNodes": [
"akka.tcp://otopcua@central-1:4053", "akka.tcp://otopcua@central-2:4053",
"akka.tcp://otopcua@central-2:4053" "akka.tcp://otopcua@central-1:4053"
] ]
The rule is conditional: it binds only when a node's own address appears in its own seed list. A
driver-only site node is seeded solely by central-1 (today's docker-dev topology) — it is legitimately
not a seed of anything and is exempt. An unconditional "self must be first" rule would refuse to boot every
site node in the fleet.
AkkaClusterOptionsValidator enforces it at startup (AddValidatedOptions → ValidateOnStart in
AddOtOpcUaCluster), because the invariant otherwise fails silently: the process runs, the port
listens, the node simply never becomes a member. Identity is compared on Cluster:PublicHostname (falling
back to Cluster:Hostname when blank) and Cluster:Port — the address Akka puts in SelfAddress.
Comparing against Cluster:Hostname would be worse than wrong: in docker-dev it is 0.0.0.0, which matches
no seed URI anywhere, so the rule would silently exempt the entire rig.
Sequential recovery is island-free by construction: a FirstSeedNodeProcess node self-joins only when no
seed answered, so a peer booting after the survivor formed simply joins it as the youngest member. Both
nodes cold-starting simultaneously converge on one cluster while they are mutually reachable — the
InitJoin handshake resolves it before either self-join deadline expires. The residual risk is a boot-time
partition (both cold-starting, mutually unreachable): both form, which is the same dual-active class the
auto-down strategy already accepts, with the same recovery (restart one side).
Pinned by SelfFirstSeedBootstrapTests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/), which starts real
in-process clusters through the production bootstrap at production failure-detection timings: a lone
cold-start with a dead peer forms alone; a restarted node rejoins its live peer; two nodes cold-starting
together converge on one cluster. Its falsifiability control — the old peer-first ordering, same
scenario, never comes Up — is what keeps the other three from going vacuous, and carries a positive-control
self-join proving the node was formable all along. AkkaClusterOptionsValidatorTests covers the validator,
including the site-node exemption and the 0.0.0.0 identity trap.
Why the self-form watchdog was retired (2026-07-22)
The first fix for this gap was Cluster:SelfFormAfter + ClusterBootstrapFallback: wait 10 s for
membership, then call Cluster.Join(SelfAddress). Both are deleted. A timer sits outside Akka's join
handshake, so it cannot distinguish "no seed answered" from "a seed answered and the join is in flight" —
and Cluster.Join(SelfAddress) is not ignored mid-handshake: it wins.
Live-gate finding (docker-dev, 2026-07-22 — preserved because it is the evidence). The watchdog islanded a node that manual failover had bounced.
central-1restarted, receivedInitJoinAckfromcentral-2at 10:49:05 — the join was in flight and healthy — but no Welcome arrived inside the window, because the peer's ring still held its previous incarnation (Exiting→Down→Removed, retired at 10:49:06). At 10:49:15 the watchdog fired and the node formed a second cluster, islanded until an operator restarted it. Manual failover deliberately produces exactly that restart, so the two halves of that plan collided. A TCP reachability guard (ea45ace1) patched that one shape — never self-form while a seed peer accepts a connection — and the earlier live gate for the watchdog itself had genuinely passed:central-2cold-started alone in 10 s and a non-seedsite-a-2correctly stayed out.
The reachability guard closed the observed failure, not the class: a peer can be TCP-reachable and not
answering, or answering and slow, and the timer cannot tell. Self-first ordering has no such race because
the decision is made inside the handshake, by Akka, on the actual InitJoin replies. It was also inert
for every non-seed node, so after this change it could never usefully fire. SelfFormBootstrapTests is
replaced by SelfFirstSeedBootstrapTests, whose Restarting_node_rejoins_its_live_peer_instead_of_islanding
is the drilled scenario above.
Live gate outstanding for the new mechanism. Self-first ordering is verified here by in-process clusters at production timings, and on the sister project (ScadaBridge
4a6341d8) on real clusters and live docker containers. It has not been re-drilled on the OtOpcUa docker-dev rig since the swap — thecentral-2cold-start-alone drill should be repeated on the rebuilt image. Owned bydocs/plans/2026-07-22-per-cluster-mesh-program.mdPhase 7 alongside the auto-down 1-vs-1 gate.
Primary data-plane gate (writes, acks, alerts emit)
Three data-plane surfaces are Primary-gated so a warm standby never touches a shared field device or duplicates a fleet-wide publish: inbound operator writes (DriverHostActor.HandleRouteNodeWrite), native-alarm acks (HandleRouteNativeAlarmAck), and the native/scripted alerts emit (ForwardNativeAlarm / ScriptedAlarmHostActor.OnEngineEmission). All three route through one policy — PrimaryGatePolicy.ShouldServiceAsPrimary(localRole, driverMemberCount) (OtOpcUa.Runtime.Drivers):
| Local role (last redundancy snapshot) | Cluster driver members | Decision |
|---|---|---|
Primary |
any | service |
Secondary / Detached |
any | deny |
| unknown (no snapshot yet, or snapshot omitted this node) | ≤ 1 (single-node / boot on a lone driver) | service (boot-window / single-node posture) |
| unknown | ≥ 2 (a real driver peer exists) | deny (default-deny — a Primary peer exists; don't act until a snapshot proves this node is it) |
Before archreview 03/S4 the unknown-role case defaulted to allow unconditionally, opening a dual-primary window: a freshly-booted secondary on a multi-node cluster serviced shared-device writes and emitted duplicate fleet-wide alerts for up to the ~10 s RedundancyStateActor heartbeat (and indefinitely if the snapshot's NodeId never matched this node). Membership is authoritative much earlier than DPS delivery, so an unknown role on a multi-driver cluster now default-denies. The driver member count is read live from Cluster.State.Members (Up members with the driver role); a non-cluster ActorRefProvider yields 0 ⇒ single-node posture. If a snapshot arrives that never mentions this node while a driver peer exists (the 03/S5 identity-mismatch shape), DriverHostActor logs a one-time Warning so the silent mismatch is diagnosable.
What a client sees during the boot window (multi-node, role unknown): an inbound write is rejected (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 AuditWriteUpdateEvent; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window ("not primary (role unknown)") from a steady-state Secondary ("not primary"). Acks are dropped (Warning-logged when role-unknown). The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
Under warm/hot redundancy both cluster nodes run ScriptedAlarmHostActor and evaluate scripted alarms, keeping each node's address space and engine state warm for instant failover. To avoid duplicate rows on /alerts and duplicate historian writes, only the Primary node publishes externally:
alertstopic emission —ScriptedAlarmHostActorandDriverHostActor.ForwardNativeAlarmsubscribe to theredundancy-stateDPS topic and cache the local node'sRedundancyRole, then gate the clusteralertspublish throughPrimaryGatePolicy(table above). The OPC UA condition-node write and inbound ack/shelve command processing remain ungated on both nodes so the secondary is always ready to serve clients after a failover.HistorianAdapterActorhistorization — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to thealertsDPS topic and translates eachAlarmTransitionEvent→AlarmHistorianEventbefore enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size.- The alarm sink's DRAIN — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously.
LocalDbStoreAndForwardSinktakes adrainGatefed fromIRedundancyRoleView— a singletonDriverHostActorpublishes itsPrimaryGatePolicyverdict to, because the drain runs on a timer and cannot receiveRedundancyStateChangeditself. A gated-off node reportsHistorianDrainState.NotPrimary. Delivery is at-least-once across a failover by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See AlarmHistorian.md.
Net effect: each alarm transition appears once on /alerts and would historize once, not once per node.
See ScriptedAlarms.md and AlarmTracking.md for the scripted-alarm engine internals.
Pair-local store (LocalDb — Phases 1 + 2)
Independently of the ServiceLevel machinery above, every driver-role node keeps a consolidated
ZB.MOM.WW.LocalDb SQLite database. Phase 2 added the alarm store-and-forward
buffer (alarm_sf_events) to it alongside the config cache, so a node that dies holding
undelivered alarm history no longer takes it to the grave — see the Primary-only drain note above
and AlarmHistorian.md. Phase 1 caches the deployed-configuration
artifact (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver
node restarting into a central-SQL-Server outage would come up with no configuration at all. With
the cache, it boots from the last artifact it applied instead, and logs a running-from-cache
signal.
- What replicates. The cache can optionally replicate to the node's redundant pair peer over
the LocalDb library's gRPC sync (HLC-stamped, last-writer-wins). Two tables replicate:
deployment_artifactsanddeployment_pointer. Replication is default-OFF and fail-closed — it stays inert untilLocalDb:SyncListenPort+ a peer are configured, and a missing/mismatchedLocalDb:Replication:ApiKeyrefuses or silently halts sync. It is enabled per-pair as an opt-in. - The replicated-cache payoff. In a pair with replication on, either node can be the one that applied the latest deploy; the peer holds a byte-identical copy. So a node that restarts during a central outage can boot the current config even if it never applied that deploy itself — its peer did, and the row replicated.
- What it does NOT cover. The cache is a boot fallback for central-SQL outages only, not a standby data path and not a replacement for central. A new deployment still requires central SQL; the cache is read only when the central fetch fails at startup, and the node resumes fresh-config behavior once central returns. It does not replicate live tag values, alarms, or historian data — only the config artifact. It is orthogonal to the ServiceLevel/primary-gate logic: a node running from cache still participates in redundancy normally.
Full detail: the enablement runbook at
docs/operations/2026-07-20-localdb-pair-replication.md
and the LocalDb section of docs/Configuration.md.
Client-side failover
The OtOpcUa Client CLI at src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI supports -F / --failover-urls for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See Client.CLI.md.
Observability
OpcUaPublishActor emits one metric on every ServiceLevel transition (it suppresses no-op repeats of the same byte):
| Metric | Type | Notes |
|---|---|---|
otopcua.redundancy.service_level_change |
Counter ({change}) |
OPC UA Server.ServiceLevel transitions emitted by the redundancy state. Tagged with level = the new byte. |
otopcua.redundancy.primary_gate_denied |
Counter ({denial}) |
Operations denied by the Primary data-plane gate (archreview 03/S4). Tagged site = write | ack | alarm-emit and reason = secondary | detached | role-unknown. A non-zero role-unknown rate on a healthy multi-node cluster flags a stuck boot window (redundancy snapshot not delivering). |
otopcua.opcua.apply.failed |
Counter ({apply}) |
Address-space apply/materialise passes that swallowed at least one sink failure (archreview 01/S-1). Tagged kind = rebuild (the rebuild threw) | nodes (per-node materialise failures). A non-zero rate means the running server holds a stale or partial address space despite a reported-successful deploy — OpcUaPublishActor also logs the degraded apply at Error. |
galaxy.writes.advise_failed |
Counter ({write}) |
Galaxy writes short-circuited to Bad because AdviseSupervisory failed — the value could not have committed, so the write fails closed (archreview 06/S-1) and the Bad status fires the #5 node revert instead of leaving a phantom-Good node. On the Galaxy driver meter ZB.MOM.WW.OtOpcUa.Driver.Galaxy. |
galaxy.writes.unconfirmed |
Counter ({write}) |
Galaxy writes reported Good off an empty gateway statuses array (command accepted; the COM-side commit is unconfirmed pending the mxaccessgw WriteComplete correlation follow-up). The honest signal for the optimistic-Good rate until that cross-repo fix lands. On the Galaxy driver meter. |
The redundancy/apply meters are defined on OtOpcUaTelemetry (src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs); the Galaxy write meters hang off the driver's own meter (EventPump.MeterName). All surface through whatever OpenTelemetry exporter the host configures.
Galaxy fail-closed write semantics (archreview 06/S-1): a non-secured Galaxy write commits only when its item is AdviseSupervisory-advised first (the writer runs with no login). If the advise fails, the write is not issued — GatewayGalaxyDataWriter returns BadCommunicationError (a knowingly-lost write is never reported as Good). An empty statuses array on the write reply is still treated as Good but metered as galaxy.writes.unconfirmed. See Historian.md / the Galaxy driver notes for the write path.
Depth reference
For the full design — message contracts, tiered calculator truth table, recovery semantics — see docs/plans/2026-05-26-akka-hosting-alignment-design.md §6.