ab561faa43
Adds LeaderAutoDowningProvider (Host/Actors), a line-for-line port of Akka.NET 1.5.62 AutoDowning/AutoDown/AutoDownBase read from the decompiled assembly: the leader among the reachable members downs a peer unreachable for StableAfter; a non-leader records it as pending and downs it on becoming leader; reachable-again or removed members are forgotten; Down/Exiting members are skipped. Its window is carried under scadabridge.cluster.leader-auto-down.unreachable-after, so no deprecated auto-down-unreachable-after key is emitted and Akka's boot-time "keep-majority will be used instead" warning goes away. DownRemovalMargin reads akka.cluster.down-removal-margin exactly as AutoDowning does (default off). New SplitBrainResolverStrategy value "leader-auto-down", wired through BuildHocon and ClusterOptionsValidator. The default stays "auto-down"; auto-down and keep-oldest HOCON is byte-identical to before. Placed in Host, not ClusterInfrastructure, per the Code Placement rule (ClusterInfrastructure stays Akka-free). Tests: HOCON + validator units; LeaderAutoDownTests ports Akka's AutoDownSpec cases (leader-only, pending-on-leader-change, losing leadership, reachable again, removed, Down/Exiting skipped, single Down per member); LeaderAutoDownFailoverTests clones SbrFailoverTests' oldest-crash and younger-crash cases on a real two-node cluster plus a test that Akka actually loaded the provider and its downing actor. Mutation-checked: a provider that never calls Cluster.Down fails both failover tests; never-downs, leader-check-dropped, reachable-ignored and pending-not-flushed mutations each fail the unit tests. Remaining gate before switching the default: the docker failover drill (DRILL_MODE=active and standby mode) on leader-auto-down. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
216 lines
9.1 KiB
C#
216 lines
9.1 KiB
C#
using System.Collections.Immutable;
|
|
using System.Reflection;
|
|
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Akka.TestKit.Xunit2;
|
|
using Akka.Util;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#106: the decision logic of <see cref="LeaderAutoDownBase"/> must match Akka.NET
|
|
/// 1.5.62 <c>AutoDownBase</c> exactly. These cases are the ones Akka's own <c>AutoDownSpec</c>
|
|
/// pins — cluster events are fed straight to the actor and the <c>Down</c> seam reports to a
|
|
/// probe, so the leader-only and reachability semantics are proven without a running cluster.
|
|
/// The two-node real-cluster behaviour is proven separately in IntegrationTests
|
|
/// (<c>LeaderAutoDownFailoverTests</c>).
|
|
/// </summary>
|
|
public class LeaderAutoDownTests : TestKit
|
|
{
|
|
private static readonly Address MemberA = new("akka.tcp", "sys", "a", 2552);
|
|
private static readonly Address MemberB = new("akka.tcp", "sys", "b", 2552);
|
|
private static readonly Address MemberC = new("akka.tcp", "sys", "c", 2552);
|
|
|
|
private sealed record DownCalled(Address Address);
|
|
|
|
/// <summary>Test double: <c>Down</c> reports to a probe instead of calling the cluster.</summary>
|
|
private sealed class TestLeaderAutoDown : LeaderAutoDownBase
|
|
{
|
|
private readonly IActorRef _probe;
|
|
|
|
public TestLeaderAutoDown(Address selfAddress, TimeSpan unreachableAfter, IActorRef probe)
|
|
: base(unreachableAfter)
|
|
{
|
|
SelfAddress = selfAddress;
|
|
_probe = probe;
|
|
}
|
|
|
|
public override Address SelfAddress { get; }
|
|
|
|
public override IScheduler Scheduler => Context.System.Scheduler;
|
|
|
|
public override void Down(Address node)
|
|
{
|
|
// Report EVERY call, leader or not: a base that wrongly downs while not leader must
|
|
// show up as an unexpected message, not be swallowed by a throw-and-restart.
|
|
_probe.Tell(new DownCalled(node));
|
|
if (!IsLeader)
|
|
{
|
|
_probe.Tell("down-called-while-not-leader");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Akka's <c>Member</c> constructors are internal; tests build members via the internal factory.</summary>
|
|
private static Member CreateMember(Address address, MemberStatus status)
|
|
{
|
|
var create = typeof(Member).GetMethod(
|
|
"Create",
|
|
BindingFlags.Static | BindingFlags.NonPublic,
|
|
new[] { typeof(UniqueAddress), typeof(int), typeof(MemberStatus), typeof(ImmutableHashSet<string>), typeof(AppVersion) })
|
|
?? throw new InvalidOperationException("Akka.Cluster.Member.Create(…) not found — Akka internals changed.");
|
|
return (Member)create.Invoke(null, new object[]
|
|
{
|
|
new UniqueAddress(address, 1), 1, status, ImmutableHashSet<string>.Empty, AppVersion.Zero,
|
|
})!;
|
|
}
|
|
|
|
private IActorRef AutoDownActor(TimeSpan unreachableAfter) =>
|
|
Sys.ActorOf(Props.Create(() => new TestLeaderAutoDown(MemberA, unreachableAfter, TestActor)));
|
|
|
|
/// <summary>A non-leader never downs an unreachable member.</summary>
|
|
[Fact]
|
|
public void NotLeader_DoesNotDownUnreachable()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberB));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberC, MemberStatus.Up)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
/// <summary>The leader downs an unreachable member immediately when the window is zero.</summary>
|
|
[Fact]
|
|
public void Leader_DownsUnreachable_WhenWindowIsZero()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberB, MemberStatus.Up)));
|
|
ExpectMsg(new DownCalled(MemberB));
|
|
}
|
|
|
|
/// <summary>The leader downs an unreachable member only after the full window.</summary>
|
|
[Fact]
|
|
public void Leader_DownsUnreachable_AfterWindow()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(2));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberB, MemberStatus.Up)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
|
ExpectMsg(new DownCalled(MemberB), TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>The initial snapshot drives leadership and schedules members already unreachable.</summary>
|
|
[Fact]
|
|
public void CurrentClusterState_Leader_DownsAlreadyUnreachable()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
var b = CreateMember(MemberB, MemberStatus.Up);
|
|
a.Tell(new ClusterEvent.CurrentClusterState(
|
|
ImmutableSortedSet<Member>.Empty,
|
|
ImmutableHashSet.Create(b),
|
|
ImmutableHashSet<Address>.Empty,
|
|
MemberA,
|
|
ImmutableDictionary<string, Address>.Empty));
|
|
ExpectMsg(new DownCalled(MemberB));
|
|
}
|
|
|
|
/// <summary>A pending member is downed as soon as this node becomes leader.</summary>
|
|
[Fact]
|
|
public void BecomingLeader_DownsPendingUnreachable()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberB));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberC, MemberStatus.Up)));
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
ExpectMsg(new DownCalled(MemberC));
|
|
}
|
|
|
|
/// <summary>A member that timed out while not leader is downed on becoming leader.</summary>
|
|
[Fact]
|
|
public void BecomingLeader_AfterWindow_DownsPendingUnreachable()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(1));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberB));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberC, MemberStatus.Up)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(2));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
ExpectMsg(new DownCalled(MemberC));
|
|
}
|
|
|
|
/// <summary>Losing leadership inside the window means no Down when the timer fires.</summary>
|
|
[Fact]
|
|
public void LosingLeadership_InsideWindow_DoesNotDown()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(2));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberC, MemberStatus.Up)));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberB));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>A member that becomes reachable again inside the window is not downed.</summary>
|
|
[Fact]
|
|
public void ReachableAgain_InsideWindow_DoesNotDown()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(2));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
var b = CreateMember(MemberB, MemberStatus.Up);
|
|
a.Tell(new ClusterEvent.UnreachableMember(b));
|
|
a.Tell(new ClusterEvent.ReachableMember(b));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>A pending member that becomes reachable again is not downed on becoming leader.</summary>
|
|
[Fact]
|
|
public void ReachableAgain_WhilePending_IsForgotten()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberB));
|
|
var c = CreateMember(MemberC, MemberStatus.Up);
|
|
a.Tell(new ClusterEvent.UnreachableMember(c));
|
|
a.Tell(new ClusterEvent.ReachableMember(c));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
/// <summary>A member removed inside the window is not downed.</summary>
|
|
[Fact]
|
|
public void RemovedInsideWindow_DoesNotDown()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(2));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
var b = CreateMember(MemberB, MemberStatus.Up);
|
|
a.Tell(new ClusterEvent.UnreachableMember(b));
|
|
a.Tell(new ClusterEvent.MemberRemoved(b.Copy(MemberStatus.Removed), MemberStatus.Up));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Members already Down or Exiting are skipped (Akka's ConvergenceSkipUnreachableWithMemberStatus).</summary>
|
|
/// <param name="status">The member status.</param>
|
|
[Theory]
|
|
[InlineData(MemberStatus.Down)]
|
|
[InlineData(MemberStatus.Exiting)]
|
|
public void AlreadyDownOrExiting_IsNotDownedAgain(MemberStatus status)
|
|
{
|
|
var a = AutoDownActor(TimeSpan.Zero);
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
a.Tell(new ClusterEvent.UnreachableMember(CreateMember(MemberB, status)));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
/// <summary>A repeated UnreachableMember for an already-scheduled member does not schedule a second Down.</summary>
|
|
[Fact]
|
|
public void RepeatedUnreachable_DownsOnce()
|
|
{
|
|
var a = AutoDownActor(TimeSpan.FromSeconds(1));
|
|
a.Tell(new ClusterEvent.LeaderChanged(MemberA));
|
|
var b = CreateMember(MemberB, MemberStatus.Up);
|
|
a.Tell(new ClusterEvent.UnreachableMember(b));
|
|
a.Tell(new ClusterEvent.UnreachableMember(b));
|
|
ExpectMsg(new DownCalled(MemberB), TimeSpan.FromSeconds(3));
|
|
ExpectNoMsg(TimeSpan.FromSeconds(2));
|
|
}
|
|
}
|