fix(redundancy): key redundancy-state snapshot by canonical host:port NodeId (was host-only — broke ServiceLevel + scripted-alarm emit gate)

This commit is contained in:
Joseph Doherty
2026-06-11 09:56:17 -04:00
parent 23a4a0093b
commit e241332a24
2 changed files with 37 additions and 1 deletions
@@ -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;
}
/// <summary>
/// Builds the canonical cluster node id (<c>host:port</c>) for an Akka member address — the SAME
/// format <c>ClusterRoleInfo.LocalNode</c>/<c>ToNodeId</c> use, so redundancy-state consumers
/// (OPC UA ServiceLevel, scripted-alarm emit gate, historian gate) can match their local node.
/// </summary>
/// <param name="address">The cluster member's address.</param>
/// <returns>The canonical <c>host:port</c> node id.</returns>
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();
@@ -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));
}
/// <summary>
/// Regression guard: the snapshot node id MUST be the canonical <c>host:port</c> form (matching
/// ClusterRoleInfo.LocalNode/ToNodeId), or every consumer's
/// <c>n.NodeId == _localNode.Value</c> match fails and no node ever learns its role.
/// </summary>
[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");
}
/// <summary>
/// Documents the host-less/port-less fallback (<c>:0</c>). Such members are skipped by
/// BuildSnapshot's guard, but the helper must still format deterministically.
/// </summary>
[Fact]
public void ToNodeId_handles_missing_port()
{
var nodeId = RedundancyStateActor.ToNodeId(new Address("akka.tcp", "otopcua"));
nodeId.Value.ShouldBe(":0");
}
}