docs(redundancy): rewrite phase 6.3 interop runbook for the current model and correct standard NodeIds (lmxopcua#716)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -1,278 +1,238 @@
|
||||
# Phase 6.3 Redundancy — Client Interop Matrix and Cutover Validation Plan
|
||||
|
||||
> **Scope**: Phase 6.3 redundancy runtime core shipped (PRs #89-90, #98-99,
|
||||
> #24-peerprobe, Stream C node wiring, Stream D lease wrap). What remains is
|
||||
> Stream F (task #150): validating that third-party OPC UA clients honour
|
||||
> our `ServiceLevel` / `ServerUriArray` / `RedundancySupport` signals and
|
||||
> fail over correctly when the Primary drops. This document defines what is
|
||||
> automatable as integration tests, what requires two live instances plus a
|
||||
> real client, and a step-by-step cutover-validation runbook.
|
||||
> **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 Up `driver` member of each application `Cluster`'s own
|
||||
> 2-node mesh) and ServiceLevel comes from `ServiceLevelCalculator`. This document is the executable
|
||||
> checklist for GA gate **G9** (see `docs/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`, `docs/v2/redundancy-interop-playbook.md`,
|
||||
> `docs/v2/implementation/phase-6-3-redundancy-runtime.md`,
|
||||
> `scripts/compliance/phase-6-3-compliance.ps1`.
|
||||
> **Source of truth**: `docs/Redundancy.md` (election, tiers, downing, manual failover, `ServerArray`),
|
||||
> `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs` and the tests named below.
|
||||
> `docs/v2/redundancy-interop-playbook.md` and `scripts/compliance/phase-6-3-compliance.ps1` are
|
||||
> **historical** (they still name the deleted types and old bands) and must not be used as pass criteria.
|
||||
|
||||
## 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)
|
||||
|
||||
The following are covered by existing automated tests that run in ordinary
|
||||
`dotnet test`:
|
||||
Ordinary `dotnet test` coverage of the pieces this runbook relies on:
|
||||
|
||||
| Area | Test class(es) | What it asserts |
|
||||
|---|---|---|
|
||||
| `ServiceLevelCalculator` — 8-state matrix | `ServiceLevelCalculatorTests` | All 10 band values; role × self-health × peer-http × peer-ua × apply × recovery × topology combinations |
|
||||
| `RecoveryStateManager` — dwell + witness | `RecoveryStateManagerTests` | 60 s dwell default; premature-exit rejection; witness-required gate |
|
||||
| `ApplyLeaseRegistry` — lease lifecycle | `ApplyLeaseRegistryTests` | Disposal on success / exception / cancellation; watchdog force-close at 10 min |
|
||||
| `ServerRedundancyNodeWriter` — OPC UA variable binding | `ServerRedundancyNodeWriterTests` | `ServiceLevel` byte push; `RedundancySupport` enum; `ServerUriArray` skip-log when node absent |
|
||||
| `RedundancyStatePublisher` — orchestration | `RedundancyStatePublisherTests` | Edge-triggered `OnStateChanged`; idempotent dedup |
|
||||
| `ClusterTopologyLoader` | `ClusterTopologyLoaderTests` | Two-node seed; one-node degenerate; duplicate-URI rejection |
|
||||
| `DraftValidator.ValidateClusterTopology` | `DraftValidatorTests` (8 cases) | NodeCount/mode pairs; Enabled-count vs NodeCount; multiple-Primary rejection |
|
||||
| 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:
|
||||
|
||||
```powershell
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Redundancy"
|
||||
```bash
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Redundancy|FullyQualifiedName~ServiceLevelCalculator|FullyQualifiedName~ManualFailover|FullyQualifiedName~DualEndpoint"
|
||||
```
|
||||
|
||||
Compliance gate (every Phase 6.3 static check):
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/compliance/phase-6-3-compliance.ps1
|
||||
```
|
||||
|
||||
Pass criteria: exit 0; all `[PASS]` lines green; `[DEFERRED]` lines are
|
||||
known-deferred surfaces, not failures.
|
||||
|
||||
## What cannot be automated — requires two live instances
|
||||
|
||||
The scenarios below require two running `OtOpcUa.Server` processes in the
|
||||
same `ServerCluster`, a real SQL Server Config DB, and at least one driver
|
||||
instance with a reachable endpoint (simulator or real PLC).
|
||||
|
||||
### Why it cannot be unit/integration-tested in-process
|
||||
|
||||
- UaExpert, Kepware KEPServerEX, and AVEVA OI Gateway are closed-source
|
||||
Windows GUI binaries with no headless CLI interface for the
|
||||
subscribe/browse flows.
|
||||
- The AVEVA MXAccess failover leg (`IAlarmSource` reconnect, `$MxAccessClient`
|
||||
quality transition) involves the Galaxy runtime's own client-redundancy
|
||||
policy and the COM-layer session model — both live outside this repo.
|
||||
- Even the automatable sub-set (our own `otopcua-cli` as the client) needs
|
||||
two distinct listening TCP endpoints; that requires two live processes,
|
||||
which is out of scope for `dotnet test` integration fixtures.
|
||||
- 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-cli` as the client) needs two distinct listening endpoints, i.e.
|
||||
two live processes, which is out of scope for `dotnet test` fixtures.
|
||||
|
||||
## Test matrix
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Two `OtOpcUa.Server` processes on separate Windows hosts (or separate
|
||||
ports on the same host for dev) sharing one Config DB (`ServerCluster`
|
||||
with `NodeCount=2`, `RedundancyMode=Warm` or `Hot`).
|
||||
2. Each node registered in `ClusterNode`:
|
||||
- Node A: `RedundancyRole=Primary`, `ServiceLevelBase=255`,
|
||||
`ApplicationUri=urn:node-a:OtOpcUa`
|
||||
- Node B: `RedundancyRole=Secondary`, `ServiceLevelBase=100`,
|
||||
`ApplicationUri=urn:node-b:OtOpcUa`
|
||||
3. `PeerHttpProbeLoop` and `PeerUaProbeLoop` HostedServices running on both
|
||||
nodes (registered via `AddHostedService<PeerHttpProbeLoop>` +
|
||||
`AddHostedService<PeerUaProbeLoop>` in `Program.cs`).
|
||||
4. At least one `DriverInstance` in the cluster with a reachable PLC or
|
||||
simulator (e.g. Modbus sim at `10.100.0.35:5020`).
|
||||
5. Client machine with UaExpert >= 1.7 installed.
|
||||
6. Optional second client: Kepware KEPServerEX 6.x QuickClient or AVEVA
|
||||
OI Gateway 2020R2+.
|
||||
1. Two `OtOpcUa.Host` processes forming **one application `Cluster`'s own 2-node Akka mesh** (see
|
||||
`docs/Redundancy.md` "Per-cluster meshes (Phase 6)"): each node carries the roles `driver` and
|
||||
`cluster-{ClusterId}` (via `Cluster:Roles`; `OTOPCUA_ROLES` is only the fallback), and each node's
|
||||
`Cluster:SeedNodes` lists **itself first**, then its partner. The cluster row has `NodeCount=2`,
|
||||
`RedundancyMode=Warm` or `Hot` (`DraftValidator` rejects other pairs).
|
||||
2. Each node has a distinct `ApplicationUri` (unique per `ClusterNode`), and each node's
|
||||
`OpcUa:PeerApplicationUris` lists its partner's `ApplicationUri` so `Server.ServerArray` returns both.
|
||||
3. There is no operator-written role and no `ServiceLevelBase` input: `ClusterNode.ServiceLevelBase` still
|
||||
exists in the schema but does not drive the runtime value. The Primary is whichever node is the oldest Up
|
||||
`driver` member, so **start Node A first and let it come Up before starting Node B** if Node A is meant to
|
||||
be Primary.
|
||||
4. At least one `DriverInstance` with a reachable simulator or PLC (e.g. the Modbus sim on the fixture host).
|
||||
5. Client machine with UaExpert >= 1.7. Optional: Kepware KEPServerEX 6.x QuickClient, AVEVA OI Gateway 2020R2+.
|
||||
|
||||
### Block A — OPC UA protocol signals (UaExpert, no failover yet)
|
||||
### Block A — OPC UA protocol signals
|
||||
|
||||
| ID | Scenario | Procedure | Pass criterion | Automatable? |
|
||||
|----|----------|-----------|----------------|--------------|
|
||||
| A1 | ServiceLevel published on Primary | Connect UaExpert to Node A. Browse `Server/ServerStatus/ServiceLevel`. | Value = 255 (`AuthoritativePrimary`) | No — requires UaExpert GUI |
|
||||
| A2 | ServiceLevel published on Backup | Connect UaExpert to Node B. Read same node. | Value = 100 (`AuthoritativeBackup`) | No |
|
||||
| A3 | ServiceLevel updates when peer drops | Node A connected. Stop Node B (`sc stop OtOpcUa`). Watch `ServiceLevel` on Node A. | Transitions 255 → 230 (`IsolatedPrimary`) within ~6 s (3 × 2 s HTTP probe interval) | No |
|
||||
| A4 | RedundancySupport | Browse `Server/ServerRedundancy/RedundancySupport` on either node. | Value = `Warm` or `Hot` matching the cluster `RedundancyMode` | No |
|
||||
| A5 | ServerUriArray | Browse `Server/ServerRedundancy/ServerUriArray` on either node. | Array contains both `ApplicationUri` values; self listed first. Note: requires non-transparent redundancy-type upgrade (currently logs-and-skips — see known limitation A5 below). | No |
|
||||
| A6 | Mid-apply ServiceLevel dip | Trigger a `sp_PublishGeneration` apply (via Admin UI draft → publish) while watching Node A `ServiceLevel`. | Drops to 200 (`PrimaryMidApply`) for the apply duration; returns to 255 after `RefreshAsync`. | No |
|
||||
| A7 | Client.CLI reads correct ServiceLevel | `dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://<node-a>:4840 -n "i=2267"` | Prints current byte value matching expected band. | **Yes** — scriptable with the Client CLI |
|
||||
| A8 | otopcua-cli failover reconnect | `dotnet run ... -- connect -u opc.tcp://<node-a>:4840 -F opc.tcp://<node-b>:4840` — then kill Node A. | CLI session reconnects to Node B within the session keep-alive timeout. | **Yes** — scriptable with the Client CLI |
|
||||
| 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`). | **Known unmet:** no code sets it, so it reports the SDK default, not the cluster's `Warm`/`Hot`. Record the observed value; do not mark 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. | **Known unmet standard-metadata gate.** The node is absent, so the read fails or is empty; the client CLI tolerates this. `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 Node A (higher ServiceLevel) |
|
||||
| B2 | UaExpert cuts over on Primary kill | Kill Node A `OtOpcUa` service. | Client session reconnects to Node B within UaExpert's reconnect timeout (default 5 s). Data-change monitored items resume. |
|
||||
| B3 | UaExpert returns when Primary restores | Start Node A. Wait >= 60 s recovery dwell. | `ServiceLevel` on Node A progresses: 180 (`RecoveringPrimary`) → 255 (`AuthoritativePrimary`). UaExpert may or may not switch back (client-policy-dependent; both outcomes accepted). |
|
||||
| B4 | Kepware QuickClient failover | Repeat B1–B3 with Kepware configured for the same two endpoints. | Same pass criteria; establishes no UaExpert-specific behaviour. |
|
||||
| B5 | AVEVA OI Gateway | Configure OI Gateway OPC DA/UA client object against the cluster. Kill Primary. | OI Gateway data quality recovers within `ReconnectInterval` (default 20 s); no permanent data-loss alert. |
|
||||
| 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
|
||||
|
||||
This block requires a running Galaxy and `$MxAccessClient` object (AVEVA
|
||||
System Platform installed, Galaxy deployed on dev box — see project memory
|
||||
`project_aveva_platform_installed.md`).
|
||||
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 | Bring cluster up. Start a Galaxy `$MxAccessClient` with both node URLs configured. | Galaxy reports `QUALITY = Good`; initial values stream from Node A. |
|
||||
| C2 | Galaxy redirects on Primary drop | Stop Node A. | Galaxy `QUALITY` briefly goes `Uncertain`, then returns to `Good`; values continue streaming from Node B within MXAccess's `ReconnectInterval` (default 20 s). |
|
||||
| C3 | Galaxy tolerates mid-apply dip | Trigger generation apply on Node A. | Galaxy remains bound — mid-apply dip (200) is advisory, not a session drop. No quality interruption. |
|
||||
| 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.) |
|
||||
|
||||
Note: A negative result on C1–C3 does not necessarily indicate an OtOpcUa
|
||||
defect. Cross-check with Block A / B first to confirm our `ServiceLevel`
|
||||
signal is correct before debugging the MXAccess client layer.
|
||||
A negative result on C1–C3 does not necessarily indicate an OtOpcUa defect; confirm Blocks A/B first.
|
||||
|
||||
## Step-by-step cutover-validation runbook
|
||||
|
||||
This is the minimum procedure to satisfy the v2 GA exit criterion:
|
||||
"Non-transparent redundancy cutover validated with at least one production
|
||||
client (Ignition 8.3 recommended — see decision #85)."
|
||||
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)". **Standard metadata gaps remain
|
||||
(A4, A5b); those rows stay unmet and the production-client cutover gate stays open until a real client
|
||||
pass is recorded.**
|
||||
|
||||
### Step 1 — Provision the cluster
|
||||
|
||||
```powershell
|
||||
# On the Config DB host, seed or verify cluster rows:
|
||||
# ServerCluster: Id=<id>, Name="test-cluster", NodeCount=2, RedundancyMode=Warm
|
||||
# ClusterNode A: NodeId="node-a", ClusterId=<id>, RedundancyRole=Primary,
|
||||
# ServiceLevelBase=255, ApplicationUri="urn:node-a:OtOpcUa"
|
||||
# ClusterNode B: NodeId="node-b", ClusterId=<id>, RedundancyRole=Secondary,
|
||||
# ServiceLevelBase=100, ApplicationUri="urn:node-b:OtOpcUa"
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Verify uniqueness constraint: no two `ClusterNode` rows share the same
|
||||
`ApplicationUri` (unique index on `ApplicationUri`).
|
||||
### Step 4 — Verify peer discovery and the unmet metadata
|
||||
|
||||
### Step 2 — Start both server instances
|
||||
|
||||
On Node A host:
|
||||
|
||||
```powershell
|
||||
# appsettings.json: Node:NodeId = "node-a"
|
||||
sc start OtOpcUa
|
||||
```bash
|
||||
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)
|
||||
```
|
||||
|
||||
On Node B host:
|
||||
Record `ServerArray` as A5 PASS/FAIL and `ServerUriArray` separately as A5b; an absent `ServerUriArray` is
|
||||
recorded as **unmet**, never as a pass via `ServerArray`.
|
||||
|
||||
```powershell
|
||||
# appsettings.json: Node:NodeId = "node-b"
|
||||
sc start OtOpcUa
|
||||
```
|
||||
### Step 5 — Primary kill and failover (B2)
|
||||
|
||||
Wait 10 s for HostedServices to complete first probe cycle.
|
||||
1. Connect the third-party client's redundancy group to both endpoints; subscribe to a variable.
|
||||
2. Stop the Primary's service (`sc stop OtOpcUaHost` on a Windows node; production service recovery restarts
|
||||
it, so stop the supervisor action or accept the restart).
|
||||
3. Observe the survivor's `ServiceLevel` go 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.
|
||||
4. Record time from stop to reconnect and any data gap.
|
||||
|
||||
### Step 3 — Verify baseline ServiceLevel via Client CLI
|
||||
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.
|
||||
|
||||
```powershell
|
||||
# Node A should report 255
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://<node-a-host>:4840 -n "i=2267"
|
||||
### Step 6 — Recovery (B3)
|
||||
|
||||
# Node B should report 100
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://<node-b-host>:4840 -n "i=2267"
|
||||
```
|
||||
1. Restart the stopped node.
|
||||
2. 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.
|
||||
3. Record whether the client switches back.
|
||||
|
||||
Pass: Node A = 255, Node B = 100.
|
||||
### Step 7 — Deployment under load (C3)
|
||||
|
||||
### Step 4 — Verify ServerUriArray
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://<node-a-host>:4840 -n "i=2271"
|
||||
```
|
||||
|
||||
Pass: array returned contains both `ApplicationUri` strings. If
|
||||
`ServerUriArray` node returns empty or an error, the non-transparent
|
||||
redundancy-type upgrade follow-up is still pending (known limitation —
|
||||
`ServerRedundancyNodeWriter.ApplyServerUriArray` logs-and-skips on the
|
||||
base `ServerRedundancyState` object type).
|
||||
|
||||
### Step 5 — Execute Primary kill + failover (B2 scenario)
|
||||
|
||||
1. Connect UaExpert (or Kepware) Redundancy Group to both endpoints.
|
||||
2. Confirm client is subscribed to at least one variable node.
|
||||
3. Kill Node A: `sc stop OtOpcUa` on Node A host.
|
||||
4. Observe:
|
||||
- Node B `ServiceLevel` should transition: 100 (`AuthoritativeBackup`)
|
||||
→ 80 (`IsolatedBackup`) within ~6 s.
|
||||
- Client should reconnect to Node B and resume data-change events.
|
||||
5. Record: time from kill to client reconnect; whether data gaps occurred.
|
||||
|
||||
### Step 6 — Verify Primary recovery (B3 scenario)
|
||||
|
||||
1. Restart Node A: `sc start OtOpcUa` on Node A host.
|
||||
2. Observe Node A `ServiceLevel` progression:
|
||||
- ~0 s: 1 (`NoData`) briefly while HostedServices start.
|
||||
- Startup: 180 (`RecoveringPrimary`) — recovery dwell gate active.
|
||||
- After >= 60 s dwell + one positive publish witness: 255 (`AuthoritativePrimary`).
|
||||
3. Observe Node B:
|
||||
- Returns to 100 (`AuthoritativeBackup`) once it sees Node A peer probe succeed.
|
||||
4. Record dwell duration and whether the client (UaExpert/Kepware) switches back.
|
||||
|
||||
### Step 7 — Execute mid-apply dip (A6 scenario)
|
||||
|
||||
1. Via Admin UI, create a trivial draft change and publish.
|
||||
2. Watch Node A `ServiceLevel` during apply.
|
||||
3. Expected: drops to 200 (`PrimaryMidApply`) for the apply duration
|
||||
(typically seconds); returns to 255 when `GenerationRefreshHostedService`
|
||||
releases the lease.
|
||||
Trigger a trivial deployment from the AdminUI while watching both nodes' `ServiceLevel`; expect no change.
|
||||
|
||||
### Step 8 — Record results
|
||||
|
||||
Copy the following block into a tracking doc:
|
||||
|
||||
```
|
||||
Run date: YYYY-MM-DD
|
||||
Release SHA: <git sha>
|
||||
Cluster: <cluster-id> Primary: node-a Backup: node-b
|
||||
Config DB: 10.100.0.35,14330
|
||||
Cluster: <cluster-id> Node A: <host> Node B: <host>
|
||||
|
||||
A1: [PASS/FAIL] evidence: <screenshot or CLI output>
|
||||
A1: [PASS/FAIL] evidence: <CLI output>
|
||||
A2: [PASS/FAIL]
|
||||
A3: [PASS/FAIL] time-to-IsolatedPrimary: <N>s
|
||||
A4: [PASS/FAIL]
|
||||
A5: [PASS/FAIL/DEFERRED - ServerUriArray upgrade pending]
|
||||
A6: [PASS/FAIL] mid-apply duration: <N>s
|
||||
A7: [PASS/FAIL] CLI output attached
|
||||
A8: [PASS/FAIL] CLI reconnect observed
|
||||
B1: [PASS/FAIL]
|
||||
B2: [PASS/FAIL] reconnect time: <N>s
|
||||
B3: [PASS/FAIL] dwell observed: <N>s
|
||||
B4: [PASS/FAIL] (Kepware)
|
||||
B5: [PASS/FAIL] (OI Gateway — if available)
|
||||
C1: [PASS/FAIL/SKIP - Galaxy not available]
|
||||
C2: [PASS/FAIL/SKIP]
|
||||
C3: [PASS/FAIL/SKIP]
|
||||
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 is the v2 GA acceptance criterion.
|
||||
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
|
||||
|
||||
### A5 — ServerUriArray node not yet writable
|
||||
### A4 / A5b — standard redundancy metadata not exposed
|
||||
|
||||
The OPC UA .NET Standard SDK's default `Server.ServerRedundancy` object is the
|
||||
base `ServerRedundancyState`, which has no `ServerUriArray` child node.
|
||||
`ServerRedundancyNodeWriter.ApplyServerUriArray` currently logs a warning and
|
||||
skips. The operator obtains `ServerUriArray` by reading `ClusterNode` rows
|
||||
directly until the non-transparent redundancy-type upgrade follow-up ships.
|
||||
OtOpcUa does not set `RedundancySupport` and does not expose `Server.ServerRedundancy.ServerUriArray`
|
||||
(`i=11314`): the SDK's default `ServerRedundancy` object is the base type with no such child. Peer discovery
|
||||
works through `Server.ServerArray` (`i=2254`) only. Clients that require the standard
|
||||
`NonTransparentRedundancyType` metadata will not recognise the pair. The metadata gap is tracked separately
|
||||
from this runbook.
|
||||
|
||||
### Recovery dwell is 60 s by default
|
||||
### Failover timing is bounded by downing, not by probes
|
||||
|
||||
`RecoveryStateManager.DwellTime` defaults to `TimeSpan.FromSeconds(60)` in
|
||||
`Program.cs`. Step 6 of the runbook will block for at least 60 s waiting for
|
||||
Node A to return to `AuthoritativePrimary`. This is intentional per
|
||||
decision #154 (thrash prevention) — do not lower it for the test run.
|
||||
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").
|
||||
|
||||
### IsolatedBackup (80) does not auto-promote
|
||||
### The returning node does not reclaim Primary
|
||||
|
||||
Per decision #154, the Backup at band 80 does not self-elevate. If the operator
|
||||
needs authoritative service from Node B while Node A is down, they must write
|
||||
`RedundancyRole=Primary` on the `ClusterNode` row for Node B and publish a
|
||||
draft generation. The Admin UI `RedundancyTab` exposes this flow.
|
||||
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 cutover runbook validates the end-to-end wire path. The math and edge cases
|
||||
are already locked by the unit/integration tests enumerated in the first section.
|
||||
A failing runbook step that contradicts a passing unit test indicates a
|
||||
deployment configuration error or an SDK version mismatch — not a logic bug.
|
||||
Check `PeerHttpProbeLoop` logs first (look for `PeerProbe` Serilog events).
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user