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 3c276c56..0afcf188 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs @@ -104,7 +104,7 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers : CommonsRedundancyRole.Detached; list.Add(new NodeRedundancyState( - NodeId.Parse(host), + ToNodeId(member.Address), role, IsClusterLeader: clusterLeader == member.Address, IsRoleLeaderForDriver: driverLeader == member.Address, @@ -113,6 +113,16 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers return list; } + /// + /// Builds the canonical cluster node id (host:port) for an Akka member address — the SAME + /// format ClusterRoleInfo.LocalNode/ToNodeId use, so redundancy-state consumers + /// (OPC UA ServiceLevel, scripted-alarm emit gate, historian gate) can match their local node. + /// + /// The cluster member's address. + /// The canonical host:port node id. + public static NodeId ToNodeId(Akka.Actor.Address address) => + NodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}"); + public sealed class RecomputeNow { public static readonly RecomputeNow Instance = new(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs index 505ada1c..5f0df58e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs @@ -42,4 +42,30 @@ public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase // After debounce settles, no more events are fired by a quiescent cluster. probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + + /// + /// Regression guard: the snapshot node id MUST be the canonical host:port form (matching + /// ClusterRoleInfo.LocalNode/ToNodeId), or every consumer's + /// n.NodeId == _localNode.Value match fails and no node ever learns its role. + /// + [Fact] + public void ToNodeId_uses_canonical_host_and_port() + { + var nodeId = RedundancyStateActor.ToNodeId( + new Address("akka.tcp", "otopcua", "central-2", 4053)); + + nodeId.Value.ShouldBe("central-2:4053"); + } + + /// + /// Documents the host-less/port-less fallback (:0). Such members are skipped by + /// BuildSnapshot's guard, but the helper must still format deterministically. + /// + [Fact] + public void ToNodeId_handles_missing_port() + { + var nodeId = RedundancyStateActor.ToNodeId(new Address("akka.tcp", "otopcua")); + + nodeId.Value.ShouldBe(":0"); + } }