using Akka.Actor; using Akka.Cluster; using Akka.Configuration; using Akka.Remote; using Akka.Remote.Transport; using Xunit; using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; using ZB.MOM.WW.ScadaBridge.Host; using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster; /// /// ScadaBridge#106: the operational scenarios of the in-repo /// beyond the hard-crash clones in — cold starts /// (together, and alone with the peer dead: the shape that makes SBR down-if-alone /// self-down on a cold recreate), a FLAPPING peer that keeps going unreachable for less than /// the window, a real partition (the accepted dual-active trade), and the opt-in switch being /// off (the old providers are what Akka loads). /// /// Nodes are built from the production BuildHocon output with the shipped self-first /// seed ordering, exactly like . The flapping and /// partition cases add Akka's own throttler transport adapter (trttl, the one the /// MultiNodeTestRunner uses for Blackhole) so the network can be cut and restored /// without killing either node. /// /// public sealed class LeaderAutoDownScenarioTests : IAsyncLifetime { private const string Strategy = "leader-auto-down"; private static readonly TimeSpan Heartbeat = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan Threshold = TimeSpan.FromSeconds(2); private readonly List _systems = new(); private ActorSystem StartNode( int selfPort, int peerPort, string strategy = Strategy, TimeSpan? stableAfter = null, bool throttled = false) { // With the throttler applied the published address scheme becomes akka.trttl.tcp, so the // seeds must carry it too or the nodes never find each other. var scheme = throttled ? "akka.trttl.tcp" : "akka.tcp"; var nodeOptions = new NodeOptions { Role = "Central", NodeHostname = "127.0.0.1", RemotingPort = selfPort }; var clusterOptions = new ClusterOptions { SeedNodes = new List { $"{scheme}://scadabridge@127.0.0.1:{selfPort}", $"{scheme}://scadabridge@127.0.0.1:{peerPort}", }, SplitBrainResolverStrategy = strategy, StableAfter = stableAfter ?? TimeSpan.FromSeconds(3), HeartbeatInterval = Heartbeat, FailureDetectionThreshold = Threshold, MinNrOfMembers = 1, DownIfAlone = true, }; // Transport failure detection far above the cluster window: a blackhole must make the // MEMBER unreachable (what the downing provider reacts to), not tear the association down. var hocon = AkkaHostedService.BuildHocon( nodeOptions, clusterOptions, new[] { "Central" }, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60)); var overrides = "akka.coordinated-shutdown.run-by-actor-system-terminate = off\n" + (throttled ? "akka.remote.dot-netty.tcp.applied-adapters = [trttl]\n" : string.Empty); var config = ConfigurationFactory.ParseString(overrides) .WithFallback(ConfigurationFactory.ParseString(hocon)); var system = ActorSystem.Create("scadabridge", config); _systems.Add(system); return system; } private static Task Throttle(ActorSystem from, ActorSystem to, ThrottleMode mode) => ((RemoteActorRefProvider)((ExtendedActorSystem)from).Provider).Transport.ManagementCommand( new SetThrottle(Akka.Cluster.Cluster.Get(to).SelfAddress, ThrottleTransportAdapter.Direction.Both, mode)); private static void AssertTwoUpNoneUnreachable(ActorSystem node, string because) { var cluster = Akka.Cluster.Cluster.Get(node); Assert.False(cluster.IsTerminated, $"{cluster.SelfAddress}: Cluster extension terminated ({because})"); Assert.Equal(MemberStatus.Up, cluster.SelfMember.Status); Assert.Equal(2, cluster.State.Members.Count(m => m.Status == MemberStatus.Up)); Assert.Empty(cluster.State.Unreachable); } /// /// Both nodes cold-start together: they converge on ONE two-member cluster and, once /// formed, nobody is downed — held for well past failure detection plus the window. /// /// A task that represents the asynchronous operation. [Fact] public async Task BothNodesColdStartTogether_FormOneCluster_AndNeitherIsDowned() { var portA = TwoNodeClusterFixture.GetFreeTcpPort(); var portB = TwoNodeClusterFixture.GetFreeTcpPort(); var nodeA = StartNode(portA, portB); var nodeB = StartNode(portB, portA); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(60)); await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(60)); Assert.IsType(Akka.Cluster.Cluster.Get(nodeA).DowningProvider); await Task.Delay(Threshold + TimeSpan.FromSeconds(3) + TimeSpan.FromSeconds(5)); AssertTwoUpNoneUnreachable(nodeA, "after a joint cold start"); AssertTwoUpNoneUnreachable(nodeB, "after a joint cold start"); } /// /// A lone cold start with the peer DEAD (the cold-recreate shape where SBR keep-oldest /// down-if-alone downs the lone node) forms a one-node cluster that stays Up past the /// window; when the peer is later cold-started it joins, and nobody is downed. /// /// A task that represents the asynchronous operation. [Fact] public async Task LoneColdStart_PeerDead_StaysUp_ThenPeerColdStartsAndJoins() { var portA = TwoNodeClusterFixture.GetFreeTcpPort(); var portB = TwoNodeClusterFixture.GetFreeTcpPort(); // nothing listening yet var nodeA = StartNode(portA, portB); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 1, TimeSpan.FromSeconds(30)); await Task.Delay(Threshold + TimeSpan.FromSeconds(3) + TimeSpan.FromSeconds(3)); var clusterA = Akka.Cluster.Cluster.Get(nodeA); Assert.False(clusterA.IsTerminated, "lone cold-started node downed itself"); Assert.Equal(MemberStatus.Up, clusterA.SelfMember.Status); var nodeB = StartNode(portB, portA); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(60)); await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(60)); await Task.Delay(TimeSpan.FromSeconds(5)); AssertTwoUpNoneUnreachable(nodeA, "after the peer joined"); AssertTwoUpNoneUnreachable(nodeB, "after the peer joined"); } /// /// A FLAPPING peer — cut off long enough to be marked unreachable, restored before the /// window ends, over and over, for far longer than the window in total — is never downed: /// each return to reachable cancels its pending Down and the next cut starts a fresh window. /// The test first proves the peer really did go unreachable on (almost) every cut, so the /// "never downed" outcome is not vacuous. /// /// A task that represents the asynchronous operation. [Fact] public async Task FlappingPeer_UnreachableShorterThanWindow_IsNeverDowned() { var stableAfter = TimeSpan.FromSeconds(8); var portA = TwoNodeClusterFixture.GetFreeTcpPort(); var portB = TwoNodeClusterFixture.GetFreeTcpPort(); var nodeA = StartNode(portA, portB, stableAfter: stableAfter, throttled: true); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 1, TimeSpan.FromSeconds(30)); var nodeB = StartNode(portB, portA, stableAfter: stableAfter, throttled: true); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(30)); await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(30)); var clusterA = Akka.Cluster.Cluster.Get(nodeA); const int flaps = 4; var flapsSeenUnreachable = 0; var started = DateTime.UtcNow; for (var i = 0; i < flaps; i++) { Assert.True(await Throttle(nodeA, nodeB, Blackhole.Instance), "SetThrottle(Blackhole) rejected"); var seen = false; var cutUntil = DateTime.UtcNow + TimeSpan.FromSeconds(4.5); while (DateTime.UtcNow < cutUntil) { seen |= clusterA.State.Unreachable.Any(); await Task.Delay(100); } Assert.True(await Throttle(nodeA, nodeB, Unthrottled.Instance), "SetThrottle(Unthrottled) rejected"); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(15)); await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(15)); if (seen) { flapsSeenUnreachable++; } } // Positive control: the cuts really produced unreachability, and the flapping lasted // longer than one window — a provider that did not cancel on reachable would have downed. Assert.True(flapsSeenUnreachable >= flaps - 1, $"only {flapsSeenUnreachable}/{flaps} cuts made the peer unreachable — the flap never happened"); Assert.True(DateTime.UtcNow - started > stableAfter * 2); await Task.Delay(stableAfter + TimeSpan.FromSeconds(2)); AssertTwoUpNoneUnreachable(nodeA, "flapping peer"); AssertTwoUpNoneUnreachable(nodeB, "flapping peer"); } /// /// A real partition longer than the window: each side is leader of its own reachable set, /// so each downs the other and BOTH stay Up — the dual-active outcome the availability-first /// decision accepts (identical to AutoDowning). Also the positive control for the flapping /// test: the same cut, held past the window, does down. /// /// A task that represents the asynchronous operation. [Fact] public async Task PartitionLongerThanWindow_EachSideDownsTheOther_BothStayUp() { var stableAfter = TimeSpan.FromSeconds(3); var portA = TwoNodeClusterFixture.GetFreeTcpPort(); var portB = TwoNodeClusterFixture.GetFreeTcpPort(); var nodeA = StartNode(portA, portB, stableAfter: stableAfter, throttled: true); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 1, TimeSpan.FromSeconds(30)); var nodeB = StartNode(portB, portA, stableAfter: stableAfter, throttled: true); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(30)); await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(30)); var addressA = Akka.Cluster.Cluster.Get(nodeA).SelfAddress; var addressB = Akka.Cluster.Cluster.Get(nodeB).SelfAddress; Assert.True(await Throttle(nodeA, nodeB, Blackhole.Instance)); await TwoNodeClusterFixture.WaitForMemberRemoved(nodeA, addressB, TimeSpan.FromSeconds(30)); await TwoNodeClusterFixture.WaitForMemberRemoved(nodeB, addressA, TimeSpan.FromSeconds(30)); foreach (var node in new[] { nodeA, nodeB }) { var cluster = Akka.Cluster.Cluster.Get(node); Assert.False(cluster.IsTerminated, $"{cluster.SelfAddress} downed itself during the partition"); Assert.Equal(MemberStatus.Up, cluster.SelfMember.Status); Assert.Equal(cluster.SelfAddress, cluster.State.Leader); } } /// /// The opt-in switch OFF: the default auto-down still loads Akka's own /// AutoDowning and keep-oldest still loads the SBR provider — the in-repo /// provider is loaded only for leader-auto-down. /// /// The configured strategy. /// The downing provider Akka must have instantiated. /// A task that represents the asynchronous operation. [Theory] [InlineData("auto-down", typeof(AutoDowning))] [InlineData("keep-oldest", typeof(Akka.Cluster.SBR.SplitBrainResolverProvider))] [InlineData(Strategy, typeof(LeaderAutoDowningProvider))] public async Task SwitchSelectsProvider_OldPathsUnchangedWhenOff(string strategy, Type expectedProvider) { var portA = TwoNodeClusterFixture.GetFreeTcpPort(); var portB = TwoNodeClusterFixture.GetFreeTcpPort(); var nodeA = StartNode(portA, portB, strategy: strategy); await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 1, TimeSpan.FromSeconds(30)); Assert.IsType(expectedProvider, Akka.Cluster.Cluster.Get(nodeA).DowningProvider); } /// Prepares the fixture before the tests run. /// A task that represents the asynchronous operation. public Task InitializeAsync() => Task.CompletedTask; /// Releases the resources held by this fixture asynchronously. /// A task that represents the asynchronous operation. public async Task DisposeAsync() { foreach (var s in _systems) { try { await s.Terminate().WaitAsync(TimeSpan.FromSeconds(10)); } catch { /* teardown */ } } } }