using Akka.Actor; using Akka.Cluster.Hosting; using Akka.Cluster.Hosting.SBR; using Akka.Configuration; 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 downing strategy — the mechanism that decides whether a node that loses its peer /// keeps running or shuts itself down. /// /// /// /// Why these tests assert the EFFECTIVE config, not the typed option. The previous /// version of this file asserted only that /// returned a /// . That is a different question from "what does the running /// ActorSystem actually do". Akka.Hosting merges several HOCON fragments — the akka.conf /// resource, whatever WithClustering emits, and anything added explicitly — and which /// one wins is not readable off the names alone. A downing /// setting can therefore be right in the resource file, right in the typed options object, /// and still not be the one in force. These tests start a real host through the production /// bootstrap and read akka.cluster.downing-provider-class back off /// , which is the only assertion precedence cannot fool. /// /// /// Why the default is auto-down. A two-node cluster running the SBR /// keep-oldest resolver cannot survive a crash of the oldest node. Akka.NET 1.5.62's /// KeepOldest.OldestDecision only lets down-if-alone rescue a side holding /// >= 2 members, so in a 1-vs-1 split the lone survivor takes DownReachable and /// downs itself — then run-coordinated-shutdown-when-down terminates it. A /// crash of one node becomes a total outage: exactly the failure the redundancy pair exists /// to absorb. Proven live on the sister project's rig (see /// ../ScadaBridge/docs/plans/2026-07-21-auto-down-availability-decision.md) and /// adopted here for the same reason. The accepted trade is dual-active during a genuine /// network partition; see docs/Redundancy.md. /// /// public sealed class SplitBrainResolverActivationTests { private static AkkaClusterOptions SampleOptions(string? strategy = null) { var options = new AkkaClusterOptions { SeedNodes = new[] { "akka.tcp://otopcua@127.0.0.1:4053" }, Roles = new[] { "admin", "driver" }, }; if (strategy is not null) { options.SplitBrainResolverStrategy = strategy; } return options; } /// /// Starts a real host through /// — the production entry point, not a re-creation of it — and returns the ActorSystem's /// effective configuration. /// /// /// Calling the production bootstrap rather than inlining the calls it makes is deliberate: an /// earlier draft of this helper rebuilt the same three steps itself, which pinned the /// test's wiring instead of the shipped one and would have stayed green through a change /// to production's HOCON precedence. /// private static async Task EffectiveConfigAsync(AkkaClusterOptions options) { // Port 0 so concurrently-running test hosts cannot collide on 4053, and no seed nodes so the // system never tries to join a cluster. options.Port = 0; options.Hostname = "127.0.0.1"; options.PublicHostname = "127.0.0.1"; options.SeedNodes = Array.Empty(); var builder = Host.CreateDefaultBuilder(); builder.ConfigureServices(services => { services.AddSingleton>(Options.Create(options)); services.AddAkka("otopcua-sbr-test", (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp)); }); using var host = builder.Build(); await host.StartAsync(); try { return host.Services.GetRequiredService().Settings.Config; } finally { await host.StopAsync(); } } /// /// The default strategy installs Akka's AutoDowning provider — the leader among the /// reachable members downs the unreachable peer, so a crash of either node fails over. /// [Fact] public async Task Default_strategy_installs_the_auto_downing_provider() { var config = await EffectiveConfigAsync(SampleOptions()); config.GetString("akka.cluster.downing-provider-class") .ShouldStartWith( "Akka.Cluster.AutoDowning, Akka.Cluster", Case.Sensitive, "the effective downing provider — not merely the configured one — must be AutoDowning, or a crash of the oldest node is a total outage"); } /// /// AutoDowning is inert without a downing window: auto-down-unreachable-after defaults to /// off, under which the provider is installed but never downs anything. Selecting the /// provider and forgetting the window would look correct and fail over never. /// [Fact] public async Task Default_strategy_sets_the_auto_down_window() { var config = await EffectiveConfigAsync(SampleOptions()); config.GetTimeSpan("akka.cluster.auto-down-unreachable-after") .ShouldBe(ServiceCollectionExtensions.DowningStableAfter); } /// /// The opt-out is real: an operator who chooses partition-safety over availability gets the SBR /// keep-oldest resolver back, in force rather than merely configured. /// [Fact] public async Task Keep_oldest_strategy_installs_the_sbr_provider() { var config = await EffectiveConfigAsync(SampleOptions("keep-oldest")); config.GetString("akka.cluster.downing-provider-class") .ShouldStartWith("Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster"); config.GetString("akka.cluster.split-brain-resolver.active-strategy").ShouldBe("keep-oldest"); config.GetBoolean("akka.cluster.split-brain-resolver.keep-oldest.down-if-alone").ShouldBeTrue(); } /// The default is auto-down, so an unconfigured deployment gets the survivable one. [Fact] public void Strategy_defaults_to_auto_down() { new AkkaClusterOptions().SplitBrainResolverStrategy.ShouldBe("auto-down"); } /// /// A typo in the strategy must fail the host at startup, not silently fall through to whichever /// provider Akka.Hosting would have installed by default — which is the fatal one. /// [Fact] public void Unknown_strategy_throws_rather_than_silently_falling_back() { var ex = Should.Throw( () => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("keep-majority"))); ex.Message.ShouldContain("keep-majority"); ex.Message.ShouldContain("auto-down"); } /// Strategy comparison is case-insensitive — config casing is not a failure mode. [Fact] public void Strategy_matching_is_case_insensitive() { Should.NotThrow(() => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("Auto-Down"))); Should.NotThrow(() => ServiceCollectionExtensions.BuildDowningHocon(SampleOptions("Keep-Oldest"))); } /// /// Under keep-oldest the SBR resolver is still set as a typed option, so the strategy stays /// explicit in code rather than depending on Akka.Cluster.Hosting's default. /// [Fact] public void Keep_oldest_sets_the_typed_resolver_option() { var options = ServiceCollectionExtensions.BuildClusterOptions(SampleOptions("keep-oldest")); options.SplitBrainResolver.ShouldBeOfType().DownIfAlone.ShouldBe(true); } /// /// Pins the two stability windows together. auto-down-unreachable-after comes from /// while the SBR resolver reads /// stable-after out of akka.conf; changing one and missing the other would leave the two /// strategies failing over at different latencies for no stated reason. /// [Fact] public void Downing_window_matches_the_akka_conf_stable_after() { var akkaConf = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig()); akkaConf.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after") .ShouldBe(ServiceCollectionExtensions.DowningStableAfter); } /// /// The stability window must stay above the failure detector's tolerance, or a node gets downed /// while its peer is merely slow. /// [Fact] public void Downing_window_exceeds_the_acceptable_heartbeat_pause() { var akkaConf = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig()); ServiceCollectionExtensions.DowningStableAfter .ShouldBeGreaterThan(akkaConf.GetTimeSpan("akka.cluster.failure-detector.acceptable-heartbeat-pause")); } /// Verifies seed nodes and roles are still threaded through unchanged. [Fact] public void BuildClusterOptions_preserves_seed_nodes_and_roles() { var input = SampleOptions(); var options = ServiceCollectionExtensions.BuildClusterOptions(input); options.SeedNodes.ShouldBe(input.SeedNodes); options.Roles.ShouldBe(input.Roles); } }