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);
}
}