69b697bc3e
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
227 lines
9.6 KiB
C#
227 lines
9.6 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Akka.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Audit;
|
|
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 a manual failover removes, on a real two-node cluster built so that the
|
|
/// oldest member is not the lowest-addressed one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The fixture mirrors <see cref="RedundancyPrimaryElectionTests"/> deliberately, and for the
|
|
/// same reason: age order and address order coincide on a freshly-formed cluster and diverge
|
|
/// after any restart. A failover that targeted the <i>role leader</i> (lowest address) rather
|
|
/// than the oldest member would, after a restart, remove the node that is <b>not</b> hosting
|
|
/// the singletons — leaving the Primary exactly where it was while taking an innocent node
|
|
/// down. Node A therefore binds the <b>higher</b> port and joins first.
|
|
/// </para>
|
|
/// <para>
|
|
/// The parity test is the durable one: it pins the service's target to the member
|
|
/// <see cref="RedundancyStateActor"/> actually names Primary, so the two five-line queries
|
|
/// cannot drift apart silently.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ManualFailoverServiceTests : IAsyncLifetime
|
|
{
|
|
// Distinct from RedundancyPrimaryElectionTests' 19530/19531 — xunit may run the two classes in
|
|
// parallel, and both need deterministic (non-zero) ports.
|
|
private const int OldestPort = 19_541; // joins FIRST -> oldest, but the HIGHER address
|
|
private const int YoungestPort = 19_540; // joins SECOND -> role leader, the LOWER address
|
|
|
|
private ActorSystem? _oldest;
|
|
private ActorSystem? _youngest;
|
|
|
|
private static Config NodeConfig(int port, string systemName, int seedPort) =>
|
|
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:{{seedPort}}"]
|
|
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("manual-failover", NodeConfig(OldestPort, "manual-failover", OldestPort));
|
|
await WaitForUpAsync(_oldest, expectedMembers: 1);
|
|
|
|
_youngest = ActorSystem.Create("manual-failover", NodeConfig(YoungestPort, "manual-failover", OldestPort));
|
|
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)
|
|
{
|
|
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)})");
|
|
}
|
|
|
|
private static (ManualFailoverService Service, RecordingAuditWriter Audit) BuildService(ActorSystem system)
|
|
{
|
|
var audit = new RecordingAuditWriter();
|
|
return (new ManualFailoverService(() => system, audit, NullLogger<ManualFailoverService>.Instance), audit);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fixture sanity check: if age order and address order did not actually diverge, the assertion
|
|
/// below would pass for the wrong reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Fixture_actually_produces_divergent_age_and_address_ordering()
|
|
{
|
|
Akka.Cluster.Cluster.Get(_oldest!).State.RoleLeader("driver")!.Port.ShouldBe(
|
|
YoungestPort,
|
|
"the fixture is only meaningful if the lowest-addressed node is the YOUNGER one");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The failover removes the oldest driver member — the node hosting the singletons — not the
|
|
/// role leader, and the survivor is then elected Primary.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FailOver_targets_the_oldest_driver_member_not_the_role_leader()
|
|
{
|
|
// Driven from the survivor, as production does: the admin node asks the Primary to leave.
|
|
var (service, audit) = BuildService(_youngest!);
|
|
|
|
var target = await service.FailOverDriverPrimaryAsync("tester");
|
|
|
|
target.ShouldNotBeNull();
|
|
target.ShouldContain(
|
|
$":{OldestPort}",
|
|
Case.Sensitive,
|
|
"the failover must remove the OLDEST driver member (which holds the singletons), not the role leader");
|
|
|
|
// The leaving node runs CoordinatedShutdown and terminates — that is what makes the process
|
|
// exit and be restarted as the youngest member.
|
|
await _oldest!.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30));
|
|
|
|
// ... and the survivor is now what RedundancyStateActor names Primary.
|
|
var snapshot = await CaptureSnapshotAsync(_youngest!);
|
|
var primaries = snapshot.Where(n => n.Role == RedundancyRole.Primary).ToList();
|
|
primaries.Count.ShouldBe(1, "exactly one node may be Primary after the swap");
|
|
primaries[0].NodeId.Value.ShouldBe($"127.0.0.1:{YoungestPort}");
|
|
|
|
audit.Events.Count.ShouldBe(1, "a manual failover must be audited exactly once");
|
|
audit.Events[0].Action.ShouldBe(ManualFailoverService.AuditAction);
|
|
audit.Events[0].Actor.ShouldBe("tester");
|
|
audit.Events[0].SourceNode.ShouldNotBeNull().ShouldContain($":{OldestPort}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// With no peer to take over, a failover is a disguised shutdown. It must refuse — and must not
|
|
/// audit, because nothing happened.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FailOver_refuses_when_no_driver_peer_exists()
|
|
{
|
|
const int LonePort = 19_542;
|
|
var lone = ActorSystem.Create("manual-failover-lone", NodeConfig(LonePort, "manual-failover-lone", LonePort));
|
|
try
|
|
{
|
|
await WaitForUpAsync(lone, expectedMembers: 1);
|
|
var (service, audit) = BuildService(lone);
|
|
|
|
var result = await service.FailOverDriverPrimaryAsync("tester");
|
|
|
|
result.ShouldBeNull("a single driver member has nowhere to fail over to");
|
|
audit.Events.ShouldBeEmpty("a refused failover changed nothing and must not be audited");
|
|
|
|
// Positive assert: the node is still a live Up member — the refusal did not half-leave it.
|
|
Akka.Cluster.Cluster.Get(lone).State.Members
|
|
.Count(m => m.Status == MemberStatus.Up)
|
|
.ShouldBe(1);
|
|
lone.WhenTerminated.IsCompleted.ShouldBeFalse();
|
|
}
|
|
finally
|
|
{
|
|
await lone.Terminate();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The service's target and <see cref="RedundancyStateActor.SelectDriverPrimary"/> are two copies
|
|
/// of the same query. This pins them together, so a change to one that is not made to the other
|
|
/// fails here rather than in production, where it would mean failing over the wrong node.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Parity_with_SelectDriverPrimary()
|
|
{
|
|
var elected = RedundancyStateActor.SelectDriverPrimary(
|
|
Akka.Cluster.Cluster.Get(_oldest!).State.Members);
|
|
|
|
var target = ManualFailoverService.FailOverCore(_oldest!, dryRun: true);
|
|
var snapshotPrimary = BuildService(_oldest!).Service.GetSnapshot().PrimaryAddress;
|
|
|
|
elected.ShouldNotBeNull();
|
|
target.ShouldBe(elected, "the node acted on must be exactly the node the election names Primary");
|
|
snapshotPrimary.ShouldBe(elected.ToString(), "the UI panel must name the same node the button acts on");
|
|
|
|
// The dry run really was dry.
|
|
await Task.Delay(500);
|
|
Akka.Cluster.Cluster.Get(_oldest!).State.Members
|
|
.Count(m => m.Status == MemberStatus.Up)
|
|
.ShouldBe(2, "a dry run must not remove anyone");
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<NodeRedundancyState>> CaptureSnapshotAsync(ActorSystem system)
|
|
{
|
|
var tcs = new TaskCompletionSource<RedundancyStateChanged>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
system.ActorOf(
|
|
RedundancyStateActor.Props(broadcast: msg =>
|
|
{
|
|
if (msg is RedundancyStateChanged changed) tcs.TrySetResult(changed);
|
|
}),
|
|
$"redundancy-{Guid.NewGuid():N}");
|
|
|
|
return (await tcs.Task.WaitAsync(TimeSpan.FromSeconds(15))).Nodes;
|
|
}
|
|
|
|
private sealed class RecordingAuditWriter : IAuditWriter
|
|
{
|
|
public List<AuditEvent> Events { get; } = new();
|
|
|
|
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
|
{
|
|
Events.Add(auditEvent);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|