4550486144
Closes the remaining half of #494, and corrects the half fixed in8dd9da7d. The shared ActiveNodeHealthCheck(role: "admin") answered the wrong question twice over. It returns Healthy for any node LACKING the role, so all four driver-only site nodes called themselves active and no consumer could find the Primary of a site Cluster. And it selects by RoleLeader - the lowest-ADDRESSED member - which is not where Akka places singletons. Replaced with ClusterPrimaryHealthCheck ("cluster-primary"). One rule: this node is active iff it is the OLDEST Up member carrying its own active role, where the active role is admin when the node has it and driver otherwise. That serves both consumers correctly - a fused admin node answers for admin, so Traefik pins the AdminUI to the node hosting the singletons; a driver-only site node answers for driver, which is exactly SelectDriverPrimary, the same election behind IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Per-mesh scoping is free after Phase 6: ClusterState.Members already contains only this node's own Cluster. SelectDriverPrimary is generalised to SelectOldestUpMemberOfRole so the age-ordering rule has one implementation. Two copies of "oldest Up member of a role" would be two chances to silently disagree about who is in charge, and the tier and the redundancy snapshot must never disagree. THE ROLE-LEADER HALF MATTERED IN PRACTICE, not just in theory. On the rebuilt rig the two orderings diverge right now: akka leader (lowest address) = central-1 oldest admin member = central-2 <- hosts the singletons8dd9da7dmade the tier a real 503 but still selected by RoleLeader, so it would have pinned Traefik to central-1 - the node NOT hosting the work. With this change Traefik correctly routes to central-2. Live-verified, exactly one 200 per mesh: MAIN central-2 200 central-1 503 traefik: central-2 UP, central-1 DOWN SITE-A site-a-1 200 site-a-2 503 SITE-B site-b-1 200 site-b-2 503 Tests: role-selection matrix and the startup-safe Degraded path in Host.Tests (26 pass); parity between the tier's selector and the redundancy snapshot's, plus role scoping, added to RedundancyPrimaryElectionTests, which forms a real two-node cluster deliberately built so the oldest member is not the lowest-addressed one (5 pass).
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 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)})");
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|