50b55ee4b9
RedundancyStateActor derived the driver Primary from ClusterState.RoleLeader("driver").
Akka offers two different notions of a "first" member and they are not the same
one: role leader is the lowest-ADDRESSED Up member (host, then port), while
ClusterSingletonManager places singletons on the OLDEST — lowest up-number.
They agree on a freshly-formed cluster, which is why every existing test passed.
They diverge after any restart: the restarted node re-joins as the youngest while
keeping its address, so if it holds the lower address it becomes role leader while
the singletons stay put. The snapshot would then name a Primary that is not
hosting the work, and every Primary-gated surface follows it — inbound device
writes, native-alarm acks, the fleet-wide alerts emit, and the alarm-history
drain would all enable on the wrong node while the node actually running the
singletons stayed gated off.
BuildSnapshot now selects the oldest Up member carrying the driver role, matching
singleton placement. Leaving members are excluded: a node handing its singletons
over must not be named Primary.
NodeRedundancyState.IsRoleLeaderForDriver is renamed IsDriverPrimary, and
NodeHealthInputs.IsDriverRoleLeader likewise. Keeping the old names would have
left the wire contract asserting a derivation the code no longer uses — the same
drift that made this defect invisible.
Proven by a real two-node cluster rather than a mock. RedundancyPrimaryElectionTests
binds the first-joining node to the HIGHER port, so oldest and lowest-address name
different nodes, and includes a fixture assertion that the divergence actually
occurred — without it the real assertion could pass for the wrong reason. Positive
control: restoring the RoleLeader derivation turns exactly the two election tests
red while the fixture check stays green.
Runtime.Tests 440 passed, ControlPlane.Tests 82, Cluster.Tests 36.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
79 lines
3.1 KiB
C#
79 lines
3.1 KiB
C#
using Akka.Cluster;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
public sealed class ServiceLevelCalculatorTests
|
|
{
|
|
/// <summary>Verifies that non-Up member statuses return a service level of zero.</summary>
|
|
/// <param name="status">The Akka cluster member status to test.</param>
|
|
[Theory]
|
|
[InlineData(MemberStatus.Down)]
|
|
[InlineData(MemberStatus.Removed)]
|
|
[InlineData(MemberStatus.Exiting)]
|
|
[InlineData(MemberStatus.Leaving)]
|
|
public void NotUp_returns_zero(MemberStatus status)
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(status,
|
|
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true));
|
|
sl.ShouldBe((byte)0);
|
|
}
|
|
|
|
/// <summary>Verifies that a fully healthy non-leader member returns a service level of 240.</summary>
|
|
[Fact]
|
|
public void Fully_healthy_non_leader_returns_240()
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
|
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false));
|
|
sl.ShouldBe((byte)240);
|
|
}
|
|
|
|
/// <summary>Verifies that a fully healthy role leader returns a service level of 250.</summary>
|
|
[Fact]
|
|
public void Fully_healthy_role_leader_returns_250()
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
|
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true));
|
|
sl.ShouldBe((byte)250);
|
|
}
|
|
|
|
/// <summary>Verifies that a database reachable but stale returns a service level of 200.</summary>
|
|
[Fact]
|
|
public void Db_reachable_but_stale_returns_200()
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
|
DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverPrimary: false));
|
|
sl.ShouldBe((byte)200);
|
|
}
|
|
|
|
/// <summary>Verifies that an unreachable and stale database returns a service level of 100.</summary>
|
|
[Fact]
|
|
public void Db_unreachable_and_stale_returns_100()
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
|
DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverPrimary: false));
|
|
sl.ShouldBe((byte)100);
|
|
}
|
|
|
|
/// <summary>Verifies that an OPC UA probe failure on a non-stale database returns zero.</summary>
|
|
[Fact]
|
|
public void Opcua_probe_fail_when_not_stale_returns_zero()
|
|
{
|
|
// (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, IsDriverPrimary: false));
|
|
sl.ShouldBe((byte)0);
|
|
}
|
|
|
|
/// <summary>Verifies that a Joining member status is treated like Up for service level grading.</summary>
|
|
[Fact]
|
|
public void Joining_member_is_treated_like_Up_for_grading()
|
|
{
|
|
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining,
|
|
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false));
|
|
sl.ShouldBe((byte)240);
|
|
}
|
|
}
|