Files
scadaproj/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ClusterActiveNodeTests.cs
T
Joseph Doherty a61e041e58 feat(health)!: active node is the oldest Up member, not the leader (0.3.0)
Promotes the two apps' private replacements for ActiveNodeHealthCheck into one
shared primitive, and retires the leader/RoleLeader selection they were written
to avoid.

Both Akka consumers had already hand-rolled a replacement rather than use this
package (ScadaBridge OldestNodeActiveHealthCheck, OtOpcUa
ClusterPrimaryHealthCheck), so the entire active-node surface here —
ActiveNodeHealthCheck, AkkaActiveNodeGate — had ZERO consumers. It was not
merely unused: it was avoided, twice, for the same reason.

Leadership is address-ordered (host, then port) and has no relationship to time;
singleton placement is age-ordered. The two agree on a freshly-formed cluster,
which is why single-node and happy-path tests never caught it. They diverge
permanently after any restart: the restarted node rejoins as the youngest but
keeps its address, so if it holds the lower address it becomes leader while the
singletons — and all the work they own — stay on the other node.

New ClusterActiveNode is the single implementation: oldest Up member, optional
role scope, plus role-preference resolution for a fused node that must answer
for the role its singletons are pinned to. ActiveNodeHealthCheck and
AkkaActiveNodeGate both delegate to it, so an endpoint gate and the
/health/active probe an orchestrator routes by cannot disagree.

BREAKING (behaviour, not signature):
- Selection is by age, not leadership.
- The role-filtered mode no longer reports Healthy for a node LACKING the role.
  That "not applicable => Healthy" made the tier answer 200 on every node and
  silently broke leader-pinning (lmxopcua#494); it is now Unhealthy by default,
  overridable via NoActiveRoleStatus. This case is reachable, not defensive —
  OtOpcUa's RoleParser admits dev-only and cluster-role-only nodes.
- Identity compares UniqueAddress, so a node restarted on the same host:port is
  correctly a different member during the overlap.

Results now carry activeRole/selfAddress/activeNode in the 0.2.0 per-entry data
object, so a standby reports WHO is active and a dashboard can render a pair
from either node's payload alone.

Tests: 45 Akka (was 39), 76 total. The age-vs-address divergence is pinned
against a real two-node cluster built so the oldest member is not the
lowest-addressed one, with a fixture sanity check so it cannot pass for the
wrong reason.
2026-07-24 13:07:56 -04:00

238 lines
9.9 KiB
C#

using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.Health.Akka;
namespace ZB.MOM.WW.Health.Akka.Tests;
/// <summary>
/// Pins <b>which</b> node the active tier names, against a real two-node cluster deliberately built
/// so the oldest member is <i>not</i> the lowest-addressed one.
/// </summary>
/// <remarks>
/// <para>
/// The divergence is the whole point. Akka offers two different "first" members:
/// <c>ClusterState.Leader</c> / <c>RoleLeader</c> is the <b>lowest-addressed</b> Up member
/// (host, then port — nothing to do with time), while <b>oldest</b> is the member with the
/// lowest up-number, which is what <c>ClusterSingletonManager</c> uses to place singletons.
/// </para>
/// <para>
/// They coincide on a freshly-formed cluster, which is why every single-node and happy-path
/// test missed it and why the leader-based check survived to 0.2.1. They diverge the moment a
/// node restarts: the restarted node rejoins as the youngest while keeping its address, so if
/// it holds the lower address it becomes leader while the singletons — and every piece of work
/// they own — stay on the other node. This fixture reproduces that state directly instead of
/// waiting for a restart.
/// </para>
/// </remarks>
public sealed class ClusterActiveNodeTests : IAsyncLifetime
{
// Fixed, unusual ports: the test needs a deterministic address ordering, which port 0 cannot give.
// Distinct from OtOpcUa's equivalent fixture (19_530/19_531) so the two suites can run at once.
private const int OldestPort = 19_541; // joins FIRST -> oldest, but the HIGHER address
private const int YoungestPort = 19_540; // joins SECOND -> cluster leader, the LOWER address
private const string SystemName = "health-active-node";
private ActorSystem? _oldest;
private ActorSystem? _youngest;
private static Config NodeConfig(int port, string roles) => 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://{{SystemName}}@127.0.0.1:{{OldestPort}}"]
roles = [{{roles}}]
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. The oldest
// node is fused (admin + driver); the younger one is driver-only, so the role-scoped
// assertions below can distinguish "oldest of this role" from "oldest overall".
_oldest = ActorSystem.Create(SystemName, NodeConfig(OldestPort, "\"admin\", \"driver\""));
await WaitForUpAsync(_oldest, expectedMembers: 1);
_youngest = ActorSystem.Create(SystemName, NodeConfig(YoungestPort, "\"driver\""));
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 = Cluster.Get(system);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.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, every
/// assertion below would pass for the wrong reason.
/// </summary>
[Fact]
public void Fixture_actually_produces_divergent_age_and_address_ordering()
{
var state = Cluster.Get(_oldest!).State;
Assert.Equal(YoungestPort, state.Leader!.Port);
Assert.Equal(YoungestPort, state.RoleLeader("driver")!.Port);
}
[Fact]
public void OldestUpMember_IsTheOldest_NotTheLeader()
{
var members = Cluster.Get(_oldest!).State.Members;
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members)!.Address.Port);
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members, "driver")!.Address.Port);
}
[Fact]
public void OldestUpMember_IsRoleScoped()
{
var members = Cluster.Get(_oldest!).State.Members;
// Only the fused node carries admin, so it owns the admin slot even though it is not the
// lowest-addressed member.
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members, "admin")!.Address.Port);
// A role nobody carries has no owner.
Assert.Null(ClusterActiveNode.OldestUpMember(members, "no-such-role"));
}
[Fact]
public void SelfIsActive_IsTrueOnlyOnTheOldestNode()
{
Assert.True(ClusterActiveNode.SelfIsActive(Cluster.Get(_oldest!)));
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!)));
}
[Fact]
public void SelfIsActive_RoleScoped_YoungestOwnsNoSlot()
{
// Driver-scoped: the fused node is still the oldest driver, so the driver-only node is standby.
Assert.True(ClusterActiveNode.SelfIsActive(Cluster.Get(_oldest!), "driver"));
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!), "driver"));
// Admin-scoped: the driver-only node does not carry admin, so it can never be the admin active
// node — even though it is the cluster leader.
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!), "admin"));
}
[Fact]
public async Task HealthCheck_IsHealthyOnTheOldestAndUnhealthyOnTheLeader()
{
var onOldest = await CheckAsync(_oldest!, new ActiveNodeHealthCheckOptions());
var onYoungest = await CheckAsync(_youngest!, new ActiveNodeHealthCheckOptions());
Assert.Equal(HealthStatus.Healthy, onOldest.Status);
// The node the old leader-based check would have called active. Exactly one 200 per cluster is
// the property an orchestrator's active-tier routing depends on.
Assert.Equal(HealthStatus.Unhealthy, onYoungest.Status);
}
[Fact]
public async Task HealthCheck_DataNamesTheActiveNode_EvenFromAStandby()
{
// A standby reporting *who* is active is what lets a dashboard render the pair from either
// node's payload alone (the 0.2.0 per-entry data object).
var result = await CheckAsync(_youngest!, new ActiveNodeHealthCheckOptions());
Assert.Equal($"akka.tcp://{SystemName}@127.0.0.1:{OldestPort}", result.Data["activeNode"]);
Assert.Equal($"akka.tcp://{SystemName}@127.0.0.1:{YoungestPort}", result.Data["selfAddress"]);
}
[Fact]
public async Task HealthCheck_RolePreference_FusedNodeAnswersForAdmin()
{
var options = new ActiveNodeHealthCheckOptions { RolePreference = ["admin", "driver"] };
var onOldest = await CheckAsync(_oldest!, options);
var onYoungest = await CheckAsync(_youngest!, options);
Assert.Equal(HealthStatus.Healthy, onOldest.Status);
Assert.Equal("admin", onOldest.Data["activeRole"]);
// Resolves to driver (it has no admin role) and loses to the older fused node.
Assert.Equal(HealthStatus.Unhealthy, onYoungest.Status);
Assert.Equal("driver", onYoungest.Data["activeRole"]);
}
[Fact]
public async Task HealthCheck_NodeOwningNoneOfThePreferredRoles_IsUnhealthyByDefault()
{
var options = new ActiveNodeHealthCheckOptions { RolePreference = ["no-such-role"] };
var result = await CheckAsync(_oldest!, options);
// Not "not applicable ⇒ Healthy": a node that owns no active work must stay out of the pool.
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("carries none of the active roles", result.Description);
Assert.DoesNotContain("activeRole", result.Data.Keys);
}
[Fact]
public async Task HealthCheck_NoActiveRoleStatus_IsOverridable()
{
var options = new ActiveNodeHealthCheckOptions
{
RolePreference = ["no-such-role"],
NoActiveRoleStatus = HealthStatus.Healthy,
};
Assert.Equal(HealthStatus.Healthy, (await CheckAsync(_oldest!, options)).Status);
}
[Fact]
public void Gate_IsActiveOnlyOnTheOldestNode()
{
Assert.True(new AkkaActiveNodeGate(ProviderFor(_oldest!)).IsActiveNode);
Assert.False(new AkkaActiveNodeGate(ProviderFor(_youngest!)).IsActiveNode);
// Role-scoped: the driver-only node cannot gate admin work.
Assert.False(new AkkaActiveNodeGate(ProviderFor(_youngest!), "admin").IsActiveNode);
}
private static IServiceProvider ProviderFor(ActorSystem system) =>
new ServiceCollection().AddSingleton(system).BuildServiceProvider();
private static async Task<HealthCheckResult> CheckAsync(
ActorSystem system,
ActiveNodeHealthCheckOptions options)
{
var check = new ActiveNodeHealthCheck(ProviderFor(system), options);
var context = new HealthCheckContext
{
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
};
return await check.CheckHealthAsync(context);
}
}