diff --git a/docs/Redundancy.md b/docs/Redundancy.md index 6e30ef91..be62fc5f 100644 --- a/docs/Redundancy.md +++ b/docs/Redundancy.md @@ -6,7 +6,7 @@ OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two or more `Ot > **Discovery surface.** The `ServerArray` path on the `Server` object is what each node populates with self + peer `ApplicationUri`s — see `OpcUaApplicationHost.PopulateServerArray` and the per-node `PeerApplicationUris` option below. The redundancy-object-type `ServerUriArray` proper (a child of `Server.ServerRedundancy`) remains deferred pending an SDK object-type upgrade; clients should read `Server.ServerArray` for peer discovery today. -> **v2 change.** v1's operator-managed `ClusterNode.RedundancyRole` column + `RedundancyCoordinator` / `ApplyLeaseRegistry` / `PeerHttpProbeLoop` are gone. Primary/secondary is now derived from **Akka cluster role-leader** for the `driver` role. The operator no longer writes a role into the DB; cluster topology (specifically the `driver` role-leader) drives ServiceLevel automatically. +> **v2 change.** v1's operator-managed `ClusterNode.RedundancyRole` column + `RedundancyCoordinator` / `ApplyLeaseRegistry` / `PeerHttpProbeLoop` are gone. Primary/secondary is now derived from **Akka cluster membership** — the oldest Up member carrying the `driver` role. The operator no longer writes a role into the DB; cluster topology drives ServiceLevel automatically. The runtime pieces live in: @@ -19,7 +19,7 @@ The runtime pieces live in: | `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. | +| `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 @@ -38,7 +38,7 @@ The four inputs are sourced locally per driver node: | `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`. | -| `IsDriverRoleLeader` | The local node's entry in the `RedundancyStateChanged` snapshot from `RedundancyStateActor`. | +| `IsDriverPrimary` | The local node's entry in the `RedundancyStateChanged` snapshot from `RedundancyStateActor`. | The resulting truth table (all tiers are now reachable at runtime): @@ -47,8 +47,8 @@ The resulting truth table (all tiers are now reachable at runtime): | 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 role-leader. | -| Healthy leader | 250 | Same as healthy follower + this node is the `driver` role-leader (+10 bonus). | +| 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 @@ -66,11 +66,23 @@ 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 holds the `driver` role-leader lease, otherwise +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`.** -> `RoleLeader("driver")` yields exactly **one** Primary across the whole Akka cluster. A fleet that +> 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 what `docker-dev/docker-compose.yml` models, 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 @@ -149,7 +161,7 @@ OTOPCUA_ROLES=admin,driver Both nodes share the same `ConfigDb` connection string; `Cluster.PublicHostname` + `Roles` are what makes them distinct in cluster gossip. The first node bootstraps the cluster (its address goes in `SeedNodes`); the second node joins via the same `SeedNodes` list. -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 role-leadership). NodeId is derived as `host:port` of the cluster `PublicHostname` (see `ClusterRoleInfo.LocalNode` for the formula). +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). > **`RedundancyStateActor` NodeId consistency (fixed).** `RedundancyStateActor` now keys each node's `NodeRedundancyState` entry by the canonical `host:port` node id (via a `ToNodeId(Address)` helper mirroring `ClusterRoleInfo.ToNodeId`). Previously it keyed by `member.Address.Host` (host-only, e.g. `central-2`); since every subscriber matches by the canonical `host:port` form, 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 makes `RedundancyStateActor` consistent with the stated contract above. Additionally, `RedundancyStateActor` now **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). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs index 96236998..0eed29db 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs @@ -7,7 +7,7 @@ public readonly record struct NodeHealthInputs( bool DbReachable, bool OpcUaProbeOk, bool Stale, - bool IsDriverRoleLeader); + bool IsDriverPrimary); /// /// Pure ServiceLevel computation per design §6. Output range 0–255, where higher = "more @@ -18,7 +18,9 @@ public readonly record struct NodeHealthInputs( /// - Member not Up/Joining: 0 (cluster cannot trust this node). /// - DB reachable + OPC UA probe ok + not stale: 240 (full service). /// - Stale config (DB reachable or not, OPC UA probe state ignored): 100 or 200 depending on DB. -/// - +10 bonus when this node holds the role-leader lease for the "driver" role. +/// - +10 bonus when this node is the driver Primary (the oldest Up member with the "driver" role — +/// where the cluster singletons live). Previously this keyed off Akka's role *leader*, the +/// lowest-addressed member, which names a different node once any node has restarted. /// public static class ServiceLevelCalculator { @@ -38,6 +40,6 @@ public static class ServiceLevelCalculator _ => 0, }; - return (byte)Math.Clamp(basis + (h.IsDriverRoleLeader ? 10 : 0), 0, 255); + return (byte)Math.Clamp(basis + (h.IsDriverPrimary ? 10 : 0), 0, 255); } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs index c1e6cca7..146451ce 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs @@ -8,9 +8,20 @@ public enum RedundancyRole { Primary, Secondary, Detached } /// Snapshot of a single node's redundancy state. Aggregated by RedundancyStateActor /// to compute fleet-wide ServiceLevel. /// +/// Canonical host:port id of the node this entry describes. +/// The node's redundancy role in the current snapshot. +/// Whether the node is the Akka cluster leader. +/// +/// Whether the node is the driver Primary — the oldest Up member carrying the driver +/// role, which is where ClusterSingletonManager places singletons. Renamed from +/// IsRoleLeaderForDriver: it was derived from ClusterState.RoleLeader("driver"), the +/// lowest-addressed member, which diverges from the oldest after any node restart. The name +/// now describes what the value means rather than how it used to be computed. +/// +/// When the snapshot was computed. public sealed record NodeRedundancyState( NodeId NodeId, RedundancyRole Role, bool IsClusterLeader, - bool IsRoleLeaderForDriver, + bool IsDriverPrimary, DateTime AsOfUtc); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs index b082e49d..c7160c5b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs @@ -111,9 +111,49 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers _log.Debug("Published RedundancyStateChanged with {Count} nodes", snapshot.Count); } + /// The cluster role that marks a node as carrying the driver runtime. + public const string DriverRole = "driver"; + + /// + /// Selects the driver Primary: the oldest Up member carrying the . + /// + /// + /// + /// This deliberately does not use ClusterState.RoleLeader(role), which it + /// previously did. Role leader is the lowest-addressed Up member with the role — + /// address order (host, then port), with no relationship to time. Oldest is the member + /// with the lowest up-number, and it is what ClusterSingletonManager uses to place + /// singletons. + /// + /// + /// On a freshly-formed cluster the two agree, which is why the discrepancy was invisible. + /// They diverge after any restart: the restarted node re-joins as the youngest but keeps + /// its address, so if it holds the lower address it becomes role leader while the + /// singletons — and all the work they own — stay on the other node. Electing it Primary + /// would enable the Primary-gated data plane (inbound writes, alarm acks, the fleet-wide + /// alerts emit, the alarm-history drain) on the node that is not hosting the work. + /// + /// + /// Only members are eligible, matching singleton placement: + /// a Leaving node is handing its singletons over and must not be named Primary. + /// + /// + /// The current cluster members, in any order. + /// The oldest Up driver member's address, or null when there is none. + public static Address? SelectDriverPrimary(IEnumerable members) + { + ArgumentNullException.ThrowIfNull(members); + + return members + .Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole)) + .OrderBy(m => m, Member.AgeOrdering) + .FirstOrDefault() + ?.Address; + } + private IReadOnlyList BuildSnapshot() { - var driverLeader = _cluster.State.RoleLeader("driver"); + var driverPrimary = SelectDriverPrimary(_cluster.State.Members); var clusterLeader = _cluster.State.Leader; var now = DateTime.UtcNow; @@ -123,15 +163,16 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers var host = member.Address.Host; if (string.IsNullOrWhiteSpace(host)) continue; - var role = member.Roles.Contains("driver") - ? (driverLeader == member.Address ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary) + var isPrimary = driverPrimary is not null && driverPrimary == member.Address; + var role = member.Roles.Contains(DriverRole) + ? (isPrimary ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary) : CommonsRedundancyRole.Detached; list.Add(new NodeRedundancyState( ToNodeId(member.Address), role, IsClusterLeader: clusterLeader == member.Address, - IsRoleLeaderForDriver: driverLeader == member.Address, + IsDriverPrimary: isPrimary, AsOfUtc: now)); } return list; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 8cd39d07..c96faec6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -652,7 +652,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Stale: !_lastDbHealth.Reachable || (now - _lastDbHealth.AsOfUtc) > _staleWindow || (now - entry.AsOfUtc) > _staleWindow, - IsDriverRoleLeader: entry.IsRoleLeaderForDriver); + IsDriverPrimary: entry.IsDriverPrimary); Self.Tell(new ServiceLevelChanged(ServiceLevelCalculator.Compute(inputs))); } @@ -661,7 +661,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// secondary → 100, _ → 0). Preserved as the back-compat / bootstrap seam. private static byte LegacyRoleOnly(NodeRedundancyState entry) => entry.Role switch { - RedundancyRole.Primary when entry.IsRoleLeaderForDriver => 240, + RedundancyRole.Primary when entry.IsDriverPrimary => 240, RedundancyRole.Primary => 200, RedundancyRole.Secondary => 100, _ => 0, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ServiceLevelCalculatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ServiceLevelCalculatorTests.cs index e644a035..7c9edbc4 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ServiceLevelCalculatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ServiceLevelCalculatorTests.cs @@ -21,7 +21,7 @@ public sealed class ServiceLevelCalculatorTests public void NotUp_returns_zero(MemberStatus status) { var sl = ServiceLevelCalculator.Compute(new(status, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)0); } @@ -30,7 +30,7 @@ public sealed class ServiceLevelCalculatorTests public void Fully_healthy_non_leader_returns_240() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } @@ -39,7 +39,7 @@ public sealed class ServiceLevelCalculatorTests public void Fully_healthy_role_leader_returns_250() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)250); } @@ -48,7 +48,7 @@ public sealed class ServiceLevelCalculatorTests public void Db_reachable_but_stale_returns_200() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)200); } @@ -57,7 +57,7 @@ public sealed class ServiceLevelCalculatorTests public void Db_unreachable_and_stale_returns_100() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverRoleLeader: false)); + DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)100); } @@ -66,7 +66,7 @@ public sealed class ServiceLevelCalculatorTests public void Opcua_probe_fail_when_not_stale_returns_zero() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)0); } @@ -75,7 +75,7 @@ public sealed class ServiceLevelCalculatorTests public void Joining_member_is_treated_like_Up() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } @@ -85,7 +85,7 @@ public sealed class ServiceLevelCalculatorTests { // basis 240 + 10 = 250, already within byte range; confirms Clamp is in path var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); ((int)sl).ShouldBeLessThanOrEqualTo(255); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyPrimaryElectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyPrimaryElectionTests.cs new file mode 100644 index 00000000..4c36156d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyPrimaryElectionTests.cs @@ -0,0 +1,184 @@ +using Akka.Actor; +using Akka.Cluster; +using Akka.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +/// +/// Pins which node elects Primary, using a real two-node +/// cluster deliberately built so that the oldest member is not the lowest-addressed one. +/// +/// +/// +/// The distinction is the whole point. Akka offers two different "first" members and they +/// disagree after any restart: +/// +/// +/// +/// +/// ClusterState.RoleLeader(role) — the lowest-addressed Up member with +/// that role. Address order is host, then port; it has nothing to do with time. +/// +/// +/// +/// +/// Oldest — the Up member with the lowest up-number, i.e. the one that has been +/// in the cluster longest. This is what ClusterSingletonManager uses to place +/// singletons, and (under keep-oldest) what the split-brain resolver keys on. +/// +/// +/// +/// +/// They coincide on a freshly-formed cluster, which is why single-node and +/// happy-path tests never caught the difference. They diverge the moment a node restarts: the +/// restarted node becomes the youngest while keeping its address, so if it happens to +/// hold the lower address it becomes role leader — and the actor would have named it Primary +/// while the cluster singletons, and every piece of work they own, stayed on the other node. +/// The Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) would +/// then be enabled on the node that is not hosting the work. +/// +/// +/// This test reproduces that divergence directly rather than waiting for a restart: node A +/// binds the higher port and joins first (so it is oldest), node B binds the +/// lower port and joins second (so it is role leader). The correct answer is A. +/// +/// +public sealed class RedundancyPrimaryElectionTests : IAsyncLifetime +{ + // Fixed, unusual ports: the test needs a deterministic address ordering, which port 0 cannot give. + private const int OldestPort = 19_531; // joins FIRST -> oldest, but the HIGHER address + private const int YoungestPort = 19_530; // joins SECOND -> role leader, the LOWER address + + private ActorSystem? _oldest; + private ActorSystem? _youngest; + + private static Config NodeConfig(int port) => ConfigurationFactory.ParseString($$""" + akka { + loglevel = "WARNING" + actor.provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster" + remote.dot-netty.tcp { + hostname = "127.0.0.1" + port = {{port}} + } + cluster { + seed-nodes = ["akka.tcp://redundancy-election@127.0.0.1:{{OldestPort}}"] + roles = ["admin", "driver"] + min-nr-of-members = 1 + run-coordinated-shutdown-when-down = off + downing-provider-class = "" + } + } + """); + + public async Task InitializeAsync() + { + // Order matters: the seed forms the cluster and is therefore the oldest member. + _oldest = ActorSystem.Create("redundancy-election", NodeConfig(OldestPort)); + await WaitForUpAsync(_oldest, expectedMembers: 1); + + _youngest = ActorSystem.Create("redundancy-election", NodeConfig(YoungestPort)); + await WaitForUpAsync(_oldest, expectedMembers: 2); + await WaitForUpAsync(_youngest, expectedMembers: 2); + } + + public async Task DisposeAsync() + { + if (_youngest is not null) await _youngest.Terminate(); + if (_oldest is not null) await _oldest.Terminate(); + } + + private static async Task WaitForUpAsync(ActorSystem system, int expectedMembers) + { + var cluster = Akka.Cluster.Cluster.Get(system); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (DateTime.UtcNow < deadline) + { + var up = cluster.State.Members.Count(m => m.Status == MemberStatus.Up); + if (up >= expectedMembers) return; + await Task.Delay(100); + } + + throw new TimeoutException( + $"cluster on port {cluster.SelfAddress.Port} never saw {expectedMembers} Up members " + + $"(saw {cluster.State.Members.Count(m => m.Status == MemberStatus.Up)})"); + } + + /// + /// Sanity check on the fixture itself: if the two orderings did not actually diverge, the real + /// assertion below would pass for the wrong reason. This asserts the setup produced the + /// divergence it claims to — role leader is the younger node, not the oldest one. + /// + [Fact] + public void Fixture_actually_produces_divergent_age_and_address_ordering() + { + var state = Akka.Cluster.Cluster.Get(_oldest!).State; + + state.RoleLeader("driver")!.Port.ShouldBe( + YoungestPort, + "the fixture is only meaningful if the lowest-addressed node is the YOUNGER one"); + } + + /// + /// The elected Primary is the oldest driver member — the node that hosts the cluster singletons — + /// not the lowest-addressed one. + /// + [Fact] + public async Task Primary_is_the_oldest_driver_member_not_the_role_leader() + { + var snapshot = await CaptureSnapshotAsync(); + + var primaries = snapshot.Where(n => n.Role == RedundancyRole.Primary).ToList(); + primaries.Count.ShouldBe(1, "exactly one node may be Primary"); + primaries[0].NodeId.Value.ShouldBe( + $"127.0.0.1:{OldestPort}", + "Primary must follow cluster-singleton placement (oldest Up member), or the Primary-gated data plane is enabled on a node that is not hosting the work"); + } + + /// The younger node is Secondary, not a second Primary and not Detached. + [Fact] + public async Task Younger_driver_member_is_secondary() + { + var snapshot = await CaptureSnapshotAsync(); + + snapshot.Single(n => n.NodeId.Value == $"127.0.0.1:{YoungestPort}") + .Role.ShouldBe(RedundancyRole.Secondary); + } + + /// + /// IsDriverPrimary agrees with Role. They feed different consumers — the OPC UA + /// ServiceLevel calculation reads the flag while the data-plane gates read the role — so a + /// disagreement would advertise one node as authoritative while gating writes on the other. + /// + [Fact] + public async Task IsDriverPrimary_flag_agrees_with_the_role() + { + var snapshot = await CaptureSnapshotAsync(); + + foreach (var node in snapshot) + { + node.IsDriverPrimary.ShouldBe( + node.Role == RedundancyRole.Primary, + $"{node.NodeId.Value} disagrees between Role and IsDriverPrimary"); + } + } + + private async Task> CaptureSnapshotAsync() + { + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + _oldest!.ActorOf( + RedundancyStateActor.Props(broadcast: msg => + { + if (msg is RedundancyStateChanged changed) tcs.TrySetResult(changed); + }), + $"redundancy-{Guid.NewGuid():N}"); + + var published = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); + return published.Nodes; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs index ce2177c0..6fb8fb8b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs @@ -17,7 +17,7 @@ public sealed class ServiceLevelCalculatorTests public void NotUp_returns_zero(MemberStatus status) { var sl = ServiceLevelCalculator.Compute(new(status, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)0); } @@ -26,7 +26,7 @@ public sealed class ServiceLevelCalculatorTests public void Fully_healthy_non_leader_returns_240() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } @@ -35,7 +35,7 @@ public sealed class ServiceLevelCalculatorTests public void Fully_healthy_role_leader_returns_250() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true)); sl.ShouldBe((byte)250); } @@ -44,7 +44,7 @@ public sealed class ServiceLevelCalculatorTests public void Db_reachable_but_stale_returns_200() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)200); } @@ -53,7 +53,7 @@ public sealed class ServiceLevelCalculatorTests public void Db_unreachable_and_stale_returns_100() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverRoleLeader: false)); + DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverPrimary: false)); sl.ShouldBe((byte)100); } @@ -63,7 +63,7 @@ public sealed class ServiceLevelCalculatorTests { // (DbReachable=true, OpcUaProbeOk=false, Stale=false) falls through to the catch-all 0. var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, - DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)0); } @@ -72,7 +72,7 @@ public sealed class ServiceLevelCalculatorTests public void Joining_member_is_treated_like_Up_for_grading() { var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining, - DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false)); sl.ShouldBe((byte)240); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs index 6b6bf0e1..e951bb60 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs @@ -106,7 +106,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest new[] { new NodeRedundancyState(TestNode, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 69b54d83..a5d07989 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -330,7 +330,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase NodeId: TestNode, Role: role, IsClusterLeader: role == RedundancyRole.Primary, - IsRoleLeaderForDriver: role == RedundancyRole.Primary, + IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index 24f10784..0b8ce73e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -216,7 +216,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase { new NodeRedundancyState(TestNode, role, IsClusterLeader: role == RedundancyRole.Primary, - IsRoleLeaderForDriver: role == RedundancyRole.Primary, + IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs index c46952fa..67f8cbbf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs @@ -66,10 +66,10 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase SpawnHost(view, driverMemberCount: 4).Tell(new RedundancyStateChanged( [ new NodeRedundancyState(TestNode, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow), // A Primary DOES exist — in another pair. It is not this node's replication peer. new NodeRedundancyState(NodeId.Parse("some-other-pair:4053"), RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId())); @@ -198,13 +198,13 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase [ new NodeRedundancyState(node, role, IsClusterLeader: role == RedundancyRole.Primary, - IsRoleLeaderForDriver: role == RedundancyRole.Primary, + IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), new NodeRedundancyState( NodeId.Parse($"{PeerHost}:4053"), role == RedundancyRole.Primary ? RedundancyRole.Secondary : RedundancyRole.Primary, IsClusterLeader: role != RedundancyRole.Primary, - IsRoleLeaderForDriver: role != RedundancyRole.Primary, + IsDriverPrimary: role != RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId()); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index df53cfa6..e5ac2a8c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -106,7 +106,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase new[] { new NodeRedundancyState(TestNode, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs index f65136c4..4acd8e79 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs @@ -38,7 +38,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase Akka.Actor.Props.Create(() => new RecordingNoopActor(spawned)); private static NodeRedundancyState State(NodeId id, RedundancyRole role) => - new(id, role, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow); + new(id, role, IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow); private static RedundancyStateChanged Snapshot(params NodeRedundancyState[] nodes) => new(nodes, CorrelationId.NewId()); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs index 0c291c83..4167c31d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs @@ -85,7 +85,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase NodeId: LocalNode, Role: role, IsClusterLeader: role == RedundancyRole.Primary, - IsRoleLeaderForDriver: role == RedundancyRole.Primary, + IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); @@ -166,7 +166,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase NodeId: new NodeId("some-other-node"), Role: RedundancyRole.Secondary, IsClusterLeader: false, - IsRoleLeaderForDriver: false, + IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 8e1330f4..2a2c03ac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -181,7 +181,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Detached, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -202,9 +202,9 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), new NodeRedundancyState(NodeId.Parse("other-node"), RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId()); actor.Tell(snapshot); @@ -225,7 +225,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId()); actor.Tell(snapshot); @@ -251,7 +251,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -277,7 +277,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -302,7 +302,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -329,7 +329,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow - TimeSpan.FromMinutes(1)), }, CorrelationId.NewId())); @@ -358,7 +358,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), @@ -369,7 +369,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Detached, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -398,7 +398,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -441,7 +441,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -469,7 +469,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -498,7 +498,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Secondary, - IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow), + IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -526,7 +526,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -548,7 +548,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase Nodes: new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); @@ -574,7 +574,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(new RedundancyStateChanged(new[] { new NodeRedundancyState(local, RedundancyRole.Primary, - IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow), }, CorrelationId.NewId())); // No direct DbHealthStatus Tell — the periodic HealthTick Ask must populate it → 250. AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs index 72b43ced..70fef2b3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs @@ -51,7 +51,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase actor.Tell(new RedundancyStateChanged( Nodes: new[] { - new NodeRedundancyState(localNode, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), + new NodeRedundancyState(localNode, RedundancyRole.Primary, IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow), }, CorrelationId: CorrelationId.NewId())); @@ -91,7 +91,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase actor.Tell(new RedundancyStateChanged( Nodes: new[] { - new NodeRedundancyState(localNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + new NodeRedundancyState(localNode, RedundancyRole.Secondary, IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow), }, CorrelationId: CorrelationId.NewId())); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 5ced6fa9..86686e86 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -102,7 +102,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase NodeId: id, Role: role, IsClusterLeader: role == RedundancyRole.Primary, - IsRoleLeaderForDriver: role == RedundancyRole.Primary, + IsDriverPrimary: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId()));