b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask, SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass TestContext.Current.CancellationToken. Test counts unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
213 lines
9.2 KiB
C#
213 lines
9.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Pins <b>which</b> node <see cref="RedundancyStateActor"/> elects Primary, using a real two-node
|
|
/// cluster deliberately built so that the oldest member is <i>not</i> the lowest-addressed one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The distinction is the whole point. Akka offers two different "first" members and they
|
|
/// disagree after any restart:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item>
|
|
/// <description>
|
|
/// <c>ClusterState.RoleLeader(role)</c> — the <b>lowest-addressed</b> Up member with
|
|
/// that role. Address order is host, then port; it has nothing to do with time.
|
|
/// </description>
|
|
/// </item>
|
|
/// <item>
|
|
/// <description>
|
|
/// <b>Oldest</b> — the Up member with the lowest up-number, i.e. the one that has been
|
|
/// in the cluster longest. This is what <c>ClusterSingletonManager</c> uses to place
|
|
/// singletons, and (under <c>keep-oldest</c>) what the split-brain resolver keys on.
|
|
/// </description>
|
|
/// </item>
|
|
/// </list>
|
|
/// <para>
|
|
/// 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 <i>youngest</i> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// This test reproduces that divergence directly rather than waiting for a restart: node A
|
|
/// binds the <b>higher</b> port and joins first (so it is oldest), node B binds the
|
|
/// <b>lower</b> port and joins second (so it is role leader). The correct answer is A.
|
|
/// </para>
|
|
/// </remarks>
|
|
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 ValueTask 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 ValueTask 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)})");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The generalised selector agrees with the driver-specific one, and is scoped to the role it is
|
|
/// asked about.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>/health/active</c> now answers from <c>SelectOldestUpMemberOfRole</c> while the redundancy
|
|
/// snapshot answers from <c>SelectDriverPrimary</c>. If those two ever disagreed, the tier would
|
|
/// report a different node as active than the one whose OPC UA ServiceLevel reads 250 — so the
|
|
/// parity is the property worth pinning, not either answer alone.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void OldestUpMemberOfRole_agrees_with_SelectDriverPrimary_and_is_role_scoped()
|
|
{
|
|
var members = Akka.Cluster.Cluster.Get(_oldest!).State.Members;
|
|
|
|
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "driver")
|
|
.ShouldBe(RedundancyStateActor.SelectDriverPrimary(members));
|
|
|
|
// Both nodes here carry admin AND driver, so the admin-scoped answer is the same oldest node
|
|
// — and, critically, still the oldest rather than the lowest-addressed role leader.
|
|
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "admin")!.Port
|
|
.ShouldBe(OldestPort);
|
|
|
|
// A role nobody carries has no owner. The health check maps this to Unhealthy: during a cold
|
|
// start nobody is active, and nobody may claim to be.
|
|
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "no-such-role").ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The elected Primary is the oldest driver member — the node that hosts the cluster singletons —
|
|
/// not the lowest-addressed one.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>The younger node is Secondary, not a second Primary and not Detached.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>IsDriverPrimary</c> agrees with <c>Role</c>. 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.
|
|
/// </summary>
|
|
[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<IReadOnlyList<NodeRedundancyState>> CaptureSnapshotAsync()
|
|
{
|
|
var tcs = new TaskCompletionSource<RedundancyStateChanged>(
|
|
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;
|
|
}
|
|
}
|