The phase-6-*.ps1 scripts checked v2 Phase 6 code that later commits deliberately
deleted (76310b88 v1 Server/Admin, #522, #524, LiteDB cache, v3 migration squash):
56/56 failures were retired files, so they could not pass. Owner retired G1; G2
plus the per-area suites cover the invariants. phase-7-compliance.ps1 is untouched.
16 KiB
Phase 6.3 Redundancy — Client Interop Matrix and Cutover Validation Plan
Revised for the current redundancy model (lmxopcua#716, source audit; not a live client pass). The original Phase 6.3 text of this plan described the v1 model (
ClusterNode.RedundancyRole,PeerHttpProbeLoop/PeerUaProbeLoop,RecoveryStateManager,ApplyLeaseRegistry, the 255/230/200/180/100/80 service-level bands). Those types and bands no longer exist. Primary/secondary is now derived from Akka cluster membership (oldest Updrivermember of each applicationCluster's own 2-node mesh) and ServiceLevel comes fromServiceLevelCalculator. This document is the executable checklist for GA gate G9 (seedocs/plans/v2-ga-lab-gates-plan.md, tracked by #615); it still requires two live nodes and a real third-party client, and no lab-client result is claimed here.Source of truth:
docs/Redundancy.md(election, tiers, downing, manual failover,ServerArray),src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.csand the tests named below.docs/v2/redundancy-interop-playbook.mdis historical (it still names the deleted types and old bands) and must not be used as pass criteria.scripts/compliance/phase-6-3-compliance.ps1was deleted when G1 was retired (2026-11-13).
Standard variables used by this plan
Identifiers verified against the OPC UA SDK constants shipped with the pinned
OPCFoundation.NetStandard.Opc.Ua.Core (VariableIds):
| Variable | NodeId | Notes |
|---|---|---|
Server.ServiceLevel |
i=2267 |
Byte; published by SdkServiceLevelPublisher. |
Server.ServerArray |
i=2254 |
String[]; the peer-discovery surface OtOpcUa actually populates (self + OpcUa:PeerApplicationUris). |
Server.ServerRedundancy.RedundancySupport |
i=3709 |
Nothing in src/ writes this; it reads the SDK default. See A4. |
Server.ServerRedundancy.ServerUriArray |
i=11314 |
The standard non-transparent redundancy variable. Not populated (the SDK's default ServerRedundancy object type has no such child). |
Server.ServerCapabilities.LocaleIdArray |
i=2271 |
Not a redundancy variable. Earlier revisions of this plan read i=2271 as ServerUriArray; that was wrong. |
Current service-level bands
From docs/Redundancy.md (ServiceLevelCalculator, the live publish path; recomputed on every ~5 s
HealthTick and on each RedundancyStateChanged):
| Byte | Meaning |
|---|---|
| 250 | Healthy and the driver Primary (oldest Up driver member of the cluster's mesh). |
| 240 | Healthy follower (DB reachable, peer OPC UA probe ok, not stale, not Primary). |
| 200 | Stale data, ConfigDb reachable. |
| 100 | ConfigDb unreachable and data stale (DB-backed nodes only; a driver-only node feeds DbReachable=true and stays 240/250). |
| 0 | Member not Up/Joining, node has no driver role, or this node's OPC UA endpoint failed to start (EndpointListening=false). |
There is no recovery-dwell band, no mid-apply band and no "isolated primary" band any more. A Primary whose
peer is stopped stays 250: OpcUaProbeOk is a peer probing this node, absent/stale results default to
true, and a departed peer never demotes it.
What is already tested (no live cluster needed)
Ordinary dotnet test coverage of the pieces this runbook relies on:
| Area | Test class(es) | What it asserts |
|---|---|---|
| ServiceLevel tiers | ServiceLevelCalculatorTests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests, tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) |
Truth table above, including endpoint-failed = 0 |
| Primary election | RedundancyPrimaryElectionTests (ControlPlane.Tests) |
Oldest Up driver member wins, even when it holds the higher address |
| Manual failover | ManualFailoverServiceTests (ControlPlane.Tests) |
Leave of the oldest driver; parity with the election |
| Endpoint-failure step-down | OpcUaEndpointStepDownActorTests, OpcUaPublishActorEndpointStateTests (Runtime.Tests) |
Failed Primary hands over only when the peer is listening; ServiceLevel 0 |
| Downing strategy | SplitBrainResolverActivationTests (Cluster.Tests) |
auto-down installed through the production bootstrap |
ServerArray peer URIs |
DualEndpointTests (OpcUaServer.IntegrationTests) |
Real OPCFoundation client reads both URIs from i=2254 on loopback |
| Topology validity | DraftValidatorTests (Configuration.Tests) |
NodeCount/RedundancyMode pairs |
Run with:
dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Redundancy|FullyQualifiedName~ServiceLevelCalculator|FullyQualifiedName~ManualFailover|FullyQualifiedName~DualEndpoint"
What cannot be automated — requires two live instances
- UaExpert, Kepware KEPServerEX and AVEVA OI Gateway are closed-source Windows GUI binaries with no headless interface for the subscribe/browse flows.
- The AVEVA MXAccess failover leg involves the Galaxy runtime's own client-redundancy policy and COM-layer session model, both outside this repo.
- Even the scriptable subset (our
otopcua-clias the client) needs two distinct listening endpoints, i.e. two live processes, which is out of scope fordotnet testfixtures.
Test matrix
Prerequisites
- Two
OtOpcUa.Hostprocesses forming one applicationCluster's own 2-node Akka mesh (seedocs/Redundancy.md"Per-cluster meshes (Phase 6)"): each node carries the rolesdriverandcluster-{ClusterId}(viaCluster:Roles;OTOPCUA_ROLESis only the fallback), and each node'sCluster:SeedNodeslists itself first, then its partner. The cluster row hasNodeCount=2,RedundancyMode=WarmorHot(DraftValidatorrejects other pairs). - Each node has a distinct
ApplicationUri(unique perClusterNode), and each node'sOpcUa:PeerApplicationUrislists its partner'sApplicationUrisoServer.ServerArrayreturns both. - There is no operator-written role and no
ServiceLevelBaseinput:ClusterNode.ServiceLevelBasestill exists in the schema but does not drive the runtime value. The Primary is whichever node is the oldest Updrivermember, so start Node A first and let it come Up before starting Node B if Node A is meant to be Primary. - At least one
DriverInstancewith a reachable simulator or PLC (e.g. the Modbus sim on the fixture host). - Client machine with UaExpert >= 1.7. Optional: Kepware KEPServerEX 6.x QuickClient, AVEVA OI Gateway 2020R2+.
Block A — OPC UA protocol signals
| ID | Scenario | Procedure | Pass criterion | Automatable? |
|---|---|---|---|---|
| A1 | ServiceLevel on Primary | Read Server/ServerStatus/ServiceLevel (i=2267) on the Primary. |
250 | Yes (Client CLI) |
| A2 | ServiceLevel on follower | Read the same node on the other node. | 240 | Yes |
| A3 | Peer stop does not demote the Primary | Stop the follower. Watch ServiceLevel on the Primary for >= 40 s. |
Stays 250 (the benefit-of-the-doubt rule). A change here is a defect or an unrelated health input. | Yes |
| A4 | RedundancySupport | Read i=3709 on either node (otopcua-cli redundancy). |
Depends on OpcUa:Redundancy:Mode (#717). Warm: Warm; None: None. Unset (the default) leaves the SDK default 0: record it as unmet, not PASS. |
Yes |
| A5 | ServerArray peer discovery |
Read i=2254 on either node. |
String[] contains both ApplicationUri values (self and peer). This is the discovery surface in use today. |
Yes |
| A5b | ServerUriArray conformance |
Read i=11314 on either node. |
Warm: [self, peers], read-only. None: BadNodeIdUnknown. Unset: the SDK's orphan node reads Good []: record it as unmet. ServerArray (A5) is not a substitute and must not turn A5b into a PASS. |
Yes |
| A6 | Endpoint-failure step-down | Occupy port 4840 on the Primary host before it starts (fused admin+driver node), or follow lmxopcua#761. | Failed node publishes 0; the peer becomes Primary and publishes 250 (see docs/Redundancy.md "Endpoint start failure: Primary step-down"). |
No |
| A7 | Client CLI reads ServiceLevel | dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-a>:4840 -n "i=2267" |
Prints the expected byte. | Yes |
| A8 | otopcua-cli failover reconnect | ... -- connect -u opc.tcp://<node-a>:4840 -F opc.tcp://<node-b>:4840, then stop Node A. |
CLI reconnects to Node B within the session keep-alive timeout. | Yes |
Block B — Third-party client failover
| ID | Scenario | Procedure | Pass criterion |
|---|---|---|---|
| B1 | UaExpert picks Primary by ServiceLevel | Configure a Redundancy Group in UaExpert with both endpoint URLs. | Client connects to the 250 node. |
| B2 | UaExpert cuts over on Primary kill | Stop the Primary's service (supervised restart is expected, see below). | Client reconnects to the surviving node within UaExpert's reconnect timeout; data-change monitored items resume. The survivor becomes oldest Up after Akka downs the dead node (auto-down, default auto-down-unreachable-after = 15s), then publishes 250. Expect the cutover to take at least that long plus the 5 s HealthTick. |
| B3 | Returning node settles as follower | Restart the stopped node. | It rejoins as the youngest member and settles at 240; it does not take Primary back (oldest-Up election). Whether the client switches is client-policy-dependent; both outcomes are accepted. |
| B4 | Kepware QuickClient failover | Repeat B1–B3 with Kepware. | Same criteria. |
| B5 | AVEVA OI Gateway | Configure the client object against both endpoints; stop the Primary. | Data quality recovers within ReconnectInterval (default 20 s); no permanent data-loss alert. |
Block C — Galaxy MXAccess failover
Requires a running Galaxy and $MxAccessClient object (AVEVA System Platform installed, Galaxy deployed).
| ID | Scenario | Procedure | Pass criterion |
|---|---|---|---|
| C1 | Galaxy binds to Primary on first connect | Start a Galaxy $MxAccessClient with both node URLs. |
QUALITY = Good; values stream from the Primary. |
| C2 | Galaxy redirects on Primary drop | Stop the Primary. | QUALITY briefly Uncertain, then Good, streaming from the survivor within ReconnectInterval (default 20 s). |
| C3 | Galaxy tolerates a deployment | Trigger a deployment (AdminUI Deployments). | Galaxy stays bound; no quality interruption. (There is no mid-apply ServiceLevel dip in the current model.) |
A negative result on C1–C3 does not necessarily indicate an OtOpcUa defect; confirm Blocks A/B first.
Step-by-step cutover-validation runbook
Minimum procedure for the v2 GA exit criterion "Non-transparent redundancy cutover validated with at least
one production client (Ignition 8.3 recommended — see decision #85)". A4/A5b are met only under
Mode=Warm (#717); under the default Unset record them as unmet. G9 passed on 2026-11-13 with otopcua-cli
(owner-approved).
Step 1 — Provision the cluster
Create/verify the ServerCluster (NodeCount=2, RedundancyMode=Warm) with two enabled ClusterNode rows
with distinct ApplicationUri values, via the AdminUI. Set per-node Cluster:Roles, self-first
Cluster:SeedNodes and OpcUa:PeerApplicationUris in each node's configuration.
Step 2 — Start both nodes, oldest first
Start Node A, wait until it is Up (/health/ready), then start Node B. Wait ~40 s for the first
HealthTick, snapshot and peer-probe cycles (probe interval 10 s, connect timeout 2 s, freshness 30 s).
Step 3 — Verify baseline with the Client CLI
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-a-host>:4840 -n "i=2267" # expect 250
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-b-host>:4840 -n "i=2267" # expect 240
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- redundancy -u opc.tcp://<node-a-host>:4840
Step 4 — Verify peer discovery and the unmet metadata
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-a-host>:4840 -n "i=2254" # ServerArray: expect both URIs
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-a-host>:4840 -n "i=11314" # ServerUriArray: expect absent/empty (unmet gate A5b)
Record ServerArray as A5 PASS/FAIL and ServerUriArray separately as A5b; an absent ServerUriArray is
recorded as unmet, never as a pass via ServerArray.
Step 5 — Primary kill and failover (B2)
- Connect the third-party client's redundancy group to both endpoints; subscribe to a variable.
- Stop the Primary's service (
sc stop OtOpcUaHoston a Windows node; production service recovery restarts it, so stop the supervisor action or accept the restart). - Observe the survivor's
ServiceLevelgo 240 -> 250 once Akka downs the dead node (about 15 s plus up to a 5 s tick), and the client resume data-change events on it. - Record time from stop to reconnect and any data gap.
Planned alternative: use the AdminUI Trigger failover button on /clusters/{id}/redundancy (graceful
Leave, Administrator only); the survivor becomes oldest and advertises 250 without the downing delay.
Step 6 — Recovery (B3)
- Restart the stopped node.
- Observe it rejoin as the youngest member and settle at 240 while the survivor stays 250. There is no recovery dwell and no automatic return of the Primary role.
- Record whether the client switches back.
Step 7 — Deployment under load (C3)
Trigger a trivial deployment from the AdminUI while watching both nodes' ServiceLevel; expect no change.
Step 8 — Record results
Run date: YYYY-MM-DD
Release SHA: <git sha>
Cluster: <cluster-id> Node A: <host> Node B: <host>
A1: [PASS/FAIL] evidence: <CLI output>
A2: [PASS/FAIL]
A3: [PASS/FAIL]
A4: [UNMET - observed value: <value>]
A5: [PASS/FAIL] ServerArray contents: <..>
A5b: [UNMET - ServerUriArray absent] (must not be PASS while the node is absent)
A6: [PASS/FAIL/SKIP]
A7: [PASS/FAIL]
A8: [PASS/FAIL]
B1..B5: [PASS/FAIL/SKIP] reconnect time: <N>s
C1..C3: [PASS/FAIL/SKIP - Galaxy not available]
One pass of every non-SKIP row, with A4/A5b either genuinely passing after the metadata work lands or being explicitly recorded as unmet, is required before G9 can be called satisfied.
Known limitations
A4 / A5b — standard redundancy metadata depends on the mode
Since #717, OpcUa:Redundancy:Mode=Warm serves NonTransparentRedundancyType, RedundancySupport=Warm and a
read-only ServerUriArray (i=11314), and None serves RedundancySupport=None. The default Unset leaves the SDK
defaults (RedundancySupport 0, orphan ServerUriArray []), so under Unset peer discovery works through
Server.ServerArray (i=2254) only. No tier may enable Warm before #907 (ScadaBridge#138). The 2026-11-13 G9 pass
(docs/plans/evidence/2026-11-13-615-g9/RESULTS.md) ran under Unset; #717 T13 L1 proved the Warm metadata.
Failover timing is bounded by downing, not by probes
A crashed Primary is replaced only after Akka marks it unreachable and auto-down removes it (15 s by
default), plus the 5 s ServiceLevel tick. A genuine partition leaves both sides active (accepted
availability-over-partition-safety trade; see docs/Redundancy.md "Split-brain / downing").
The returning node does not reclaim Primary
Election is oldest-Up, so a restarted node is the youngest and stays a follower (240) until the other node leaves the mesh.
Dependency on existing tests
The runbook validates the end-to-end wire path; the math and edge cases are locked by the tests enumerated
above. A failing step that contradicts a passing unit test indicates a deployment configuration error
(roles, seed order, PeerApplicationUris) or an SDK version mismatch. Check the PeerOpcUaProbeActor /
OpcUaPublishActor logs first.