using System.Net; using System.Net.Sockets; using Akka.Actor; using Akka.Cluster; using Akka.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// /// Guards the self-first seed-ordering invariant (decision 2026-07-22): every node that is a seed /// of its own mesh lists ITSELF as SeedNodes[0] and its partner second. /// /// /// /// Why the ordering is the mechanism. Akka runs a different bootstrap process /// depending on whether seed-nodes[0] is this node's own address. When it is, /// FirstSeedNodeProcess runs: it InitJoins the other seeds and self-joins once /// seed-node-timeout (5 s) passes with nobody answering. When it is not, /// JoinSeedNodeProcess runs, and that process can never form a new cluster — it /// retries InitJoin forever. That is why "both peers are listed in SeedNodes" never /// meant "either can cold-start alone", and why a node listed second stayed out of the /// cluster indefinitely while its peer was down. /// /// /// Why not the self-form watchdog this replaces. ClusterBootstrapFallback /// waited Cluster:SelfFormAfter for membership and then called /// Cluster.Join(SelfAddress). A timer outside Akka's join handshake cannot /// distinguish "no seed answered" from "a seed answered and the join is in flight", and /// Join(SelfAddress) is not ignored mid-handshake — it wins. The live gate on /// the docker-dev rig (2026-07-22) caught exactly that: a node bounced by a manual failover /// restarted, got InitJoinAck from its live peer, but no Welcome inside the window /// because the peer's ring still held its previous incarnation /// (ExitingDownRemoved); the watchdog fired and formed a SECOND /// cluster. A TCP reachability guard patched that one shape; the race stayed. /// is that scenario, /// and self-first ordering has no such race because the decision is made inside the /// handshake by Akka itself. /// /// /// Why these start real hosts through the production bootstrap. Same reason as /// : the question is what a running node does. /// Every node here is built with /// — the shipped entry /// point — so these run at the production failure-detection envelope (heartbeat 2 s, /// acceptable pause 10 s, auto-down 15 s, down-removal-margin 15 s) from /// Resources/akka.conf rather than at timings invented by the test. /// /// public sealed class SelfFirstSeedBootstrapTests { private static int FreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// /// Starts a node the way every shipped node config is written: its own address first, the /// partner second. /// /// Actor-system name; also the system part of every seed address. /// This node's remoting port. /// The partner's remoting port (may have nothing listening on it). /// /// reproduces the pre-2026-07-22 ordering (partner first), which is what /// makes a falsifiability /// control rather than a restatement of the fix. /// private static async Task StartNodeAsync( string systemName, int selfPort, int peerPort, bool selfFirst = true) { var self = $"akka.tcp://{systemName}@127.0.0.1:{selfPort}"; var peer = $"akka.tcp://{systemName}@127.0.0.1:{peerPort}"; var options = new AkkaClusterOptions { SystemName = systemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1", Port = selfPort, Roles = new[] { "admin", "driver" }, SeedNodes = selfFirst ? new[] { self, peer } : new[] { peer, self }, }; var builder = Host.CreateDefaultBuilder(); builder.ConfigureServices(services => { services.AddSingleton>(Options.Create(options)); services.AddAkka(systemName, (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); // Crash simulation, test-only: with this off, ActorSystem.Terminate() skips // CoordinatedShutdown and so gossips no Leave — a process death, not a graceful // drain. Without it "restart the standby" would degenerate into the easy path where // the peer had already removed the old member before the new one dialled in. // Prepend is Akka.Hosting's highest-precedence merge mode (see BuildDowningHocon). ab.AddHocon( "akka.coordinated-shutdown.run-by-actor-system-terminate = off", HoconAddMode.Prepend); }); }); var host = builder.Build(); await host.StartAsync(); return host; } /// Polls until the node sees Up members, or the deadline passes. private static async Task WaitForUpMembersAsync(IHost host, int expected, TimeSpan timeout) { var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService()); var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected) { return true; } await Task.Delay(200); } return cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected; } private static async Task StopAsync(IHost host) { try { await host.StopAsync(); } catch (Exception) { // Teardown only. A host whose ActorSystem was terminated by a crash-simulation cannot // run CoordinatedShutdown again; that must not fail the test that already asserted. } host.Dispose(); } /// /// The unattended cold-start-alone guarantee: a node whose partner is dead forms the cluster by /// itself, through Akka's own FirstSeedNodeProcess — no watchdog, no timer, no operator. /// [Fact] public async Task Lone_cold_start_forms_a_cluster_when_the_peer_is_dead() { var selfPort = FreePort(); var deadPeerPort = FreePort(); // nothing is listening there var host = await StartNodeAsync("otopcua-selffirst-1", selfPort, deadPeerPort); try { // seed-node-timeout is Akka's 5 s default; 30 s is slack for a loaded CI box. (await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30))) .ShouldBeTrue("a self-first seed must form a cluster alone when no peer answers InitJoin"); } finally { await StopAsync(host); } } /// /// FALSIFIABILITY CONTROL for the test above. With the OLD ordering — partner first — the /// identical scenario never comes Up, because JoinSeedNodeProcess has no self-join path. /// If this test ever starts passing quickly, the seed ordering has stopped being the thing doing /// the work and the suite above has gone vacuous. /// /// /// This is also the test that fails while the retired ClusterBootstrapFallback watchdog /// is still armed: that timer would bring this node Up at SelfFormAfter (10 s) despite the /// ordering, which is precisely the "something other than Akka's handshake is deciding" that the /// retirement removes. /// [Fact] public async Task Peer_first_ordering_is_the_outage_gap_and_never_forms() { var selfPort = FreePort(); var deadPeerPort = FreePort(); var host = await StartNodeAsync("otopcua-selffirst-2", selfPort, deadPeerPort, selfFirst: false); try { // 3x the 5 s seed-node-timeout, and longer than the retired watchdog's 10 s window. (await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(15))) .ShouldBeFalse("a node listed behind its peer cannot form a cluster — it InitJoin-loops forever"); var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService()); cluster.State.Members.ShouldBeEmpty(); // Positive control: the node was formable all along; only the ordering blocked it. cluster.Join(cluster.SelfAddress); (await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30))) .ShouldBeTrue("positive control — an explicit self-join must bring this node Up"); } finally { await StopAsync(host); } } /// /// The case that killed the self-form watchdog (live gate, docker-dev, 2026-07-22): a routine /// standby restart while the peer is alive must REJOIN, never island. Under self-first ordering /// the restarted node's FirstSeedNodeProcess only self-joins when no seed answers, so the /// live peer's InitJoinAck keeps it on the join path however long the peer takes to /// retire its previous incarnation. /// [Fact] public async Task Restarting_node_rejoins_its_live_peer_instead_of_islanding() { const string systemName = "otopcua-selffirst-3"; var portA = FreePort(); var portB = FreePort(); var nodeA = await StartNodeAsync(systemName, portA, portB); IHost? nodeB = null; IHost? nodeB2 = null; try { (await WaitForUpMembersAsync(nodeA, 1, TimeSpan.FromSeconds(30))).ShouldBeTrue(); nodeB = await StartNodeAsync(systemName, portB, portA); (await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(60))) .ShouldBeTrue("the pair must converge before the restart is meaningful"); // Hard-crash B and immediately restart it at the SAME address, as a service restart or // container recreate does. No Leave gossip: A still holds B's previous incarnation. await nodeB.Services.GetRequiredService().Terminate() .WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); nodeB2 = await StartNodeAsync(systemName, portB, portA); // ONE cluster of two, never two clusters of one. The budget covers auto-down (15 s) plus // down-removal-margin (15 s) before the new incarnation is admitted. (await WaitForUpMembersAsync(nodeB2, 2, TimeSpan.FromSeconds(120))) .ShouldBeTrue("the restarted node must rejoin its live peer, not form a second cluster"); (await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(120))) .ShouldBeTrue("the surviving node must see the restarted peer as a member of ITS cluster"); } finally { if (nodeB2 is not null) await StopAsync(nodeB2); if (nodeB is not null) await StopAsync(nodeB); await StopAsync(nodeA); } } /// /// The obvious objection to self-first on BOTH nodes: does a simultaneous cold start produce two /// clusters? While the two are mutually reachable it does not — each runs /// FirstSeedNodeProcess, and the InitJoin handshake resolves which one forms before /// either self-join deadline expires. (A genuine boot-time PARTITION would still split, the same /// dual-active class the auto-down strategy already accepts, with the same recovery.) /// [Fact] public async Task Both_nodes_cold_starting_together_converge_on_one_cluster() { const string systemName = "otopcua-selffirst-4"; var portA = FreePort(); var portB = FreePort(); var nodeA = await StartNodeAsync(systemName, portA, portB); var nodeB = await StartNodeAsync(systemName, portB, portA); try { (await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(60))) .ShouldBeTrue("both nodes cold-starting self-first must converge, not split"); (await WaitForUpMembersAsync(nodeB, 2, TimeSpan.FromSeconds(60))) .ShouldBeTrue("both nodes cold-starting self-first must converge, not split"); } finally { await StopAsync(nodeB); await StopAsync(nodeA); } } }