using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
///
/// Pure decision logic for the simultaneous-cold-start split-brain guard.
///
///
///
/// Both nodes of a 2-node pair are self-first seeds so either can cold-start alone when
/// its partner is dead (see ). The cost is that when
/// BOTH cold-start at the same instant, each runs Akka's FirstSeedNodeProcess, times out
/// waiting for the other, and forms its OWN single-node cluster — a split brain (two Primaries
/// in one pair). Two independent clusters do not auto-merge, so the split persists until an
/// operator intervenes.
///
///
/// This guard breaks the symmetry deterministically without giving up cold-start-alone. The
/// node with the lexicographically lower canonical address is the preferred
/// founder: it always uses self-first order and forms immediately. The higher node
/// first probes whether its partner's Akka endpoint is reachable (see
/// ): reachable ⇒ peer-first order so it JOINS the
/// founder rather than racing it; unreachable after the probe window ⇒ the partner is genuinely
/// down, so it falls back to self-first and forms alone. Exactly one node founds when both are
/// present; the lower node always founds when it starts alone.
///
///
/// Residual trade-off (accepted). Once the higher node observes the partner reachable it
/// commits to peer-first — Akka's JoinSeedNodeProcess retries InitJoin forever and
/// never self-forms. If the founder dies in the small window between the probe succeeding and
/// the join completing, the higher node hangs unjoined until it is restarted (a restart re-runs
/// the guard, finds the founder unreachable, and self-forms). We deliberately do NOT re-decide
/// mid-handshake — that is exactly the retired SelfFormAfter failure mode. Instead
/// makes the hang operator-visible with a warning if
/// the node has not come Up within a bounded time after committing peer-first.
///
///
/// This decides the join order BEFORE issuing a single JoinSeedNodes, from an explicit
/// reachability signal — unlike the retired SelfFormAfter watchdog, which fired a
/// Join(self) mid-handshake on a bare timeout and could not tell "no seed answered" from
/// "a join is in flight", islanding a node a failover had just bounced.
///
///
public static class ClusterBootstrapGuard
{
private static readonly Regex SeedPattern =
new(@"^akka(?:\.[a-z0-9]+)?://[^@/]+@(?[^:/]+):(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// The analyzed bootstrap role of this node within its seed list.
/// True only when the guard engages: exactly two seeds, one of them this node.
/// True when this node is the preferred founder (lower address) — form immediately, no probe.
/// The partner's advertised host (to probe when this node is the higher one); null when not applicable.
/// The partner's Akka port.
/// [self, partner] — self-first; forms a new cluster when no peer answers.
/// [partner, self] — peer-first; joins the partner's cluster, never self-forms while it is up.
public sealed record BootstrapRole(
bool Applies,
bool IsFounder,
string? PartnerHost,
int PartnerPort,
string[] SelfFirstOrder,
string[] PeerFirstOrder);
///
/// Analyzes the configured seed list to decide this node's bootstrap role. The guard engages only
/// for the Phase-6 pair shape (exactly two seeds, one of them this node); any other shape
/// (single-seed legacy site node, three+ seeds, self absent) returns
/// = false and the caller joins the configured seeds unchanged.
///
/// The configured seed URIs (self-first by convention, but order is not relied on here).
/// This node's advertised host ().
/// This node's Akka port ().
/// The analyzed role; never null.
public static BootstrapRole Analyze(IReadOnlyList seeds, string selfHost, int selfPort)
{
ArgumentNullException.ThrowIfNull(seeds);
ArgumentException.ThrowIfNullOrWhiteSpace(selfHost);
var na = new BootstrapRole(false, false, null, 0, Array.Empty(), Array.Empty());
if (seeds.Count != 2) return na;
var selfKey = Key(selfHost, selfPort);
string? self = null, partner = null;
string? partnerHost = null;
var partnerPort = 0;
foreach (var seed in seeds)
{
if (!TryParse(seed, out var host, out var port)) return na;
if (string.Equals(Key(host, port), selfKey, StringComparison.OrdinalIgnoreCase))
self = seed;
else
{
partner = seed;
partnerHost = host;
partnerPort = port;
}
}
// Self must be exactly one of the two, and the other must be a distinct partner.
if (self is null || partner is null || partnerHost is null) return na;
var selfFirst = new[] { self, partner };
var peerFirst = new[] { partner, self };
// Deterministic tie-break: the lower canonical address is the preferred founder. Both nodes
// compute the same comparison (same two seeds), so exactly one is the founder. Case-INSENSITIVE
// to match how self/partner are classified above (and AkkaClusterOptionsValidator.IsSelf):
// container/DNS hostnames are conventionally case-insensitive, and a casing difference between
// the two sides' seed config must NOT make both think they are the founder (which would reopen
// the very split this guard closes).
var isFounder = string.Compare(
Key(selfHost, selfPort),
Key(partnerHost, partnerPort),
StringComparison.OrdinalIgnoreCase) < 0;
return new BootstrapRole(true, isFounder, partnerHost, partnerPort, selfFirst, peerFirst);
}
///
/// The order the higher (non-founder) node uses once it has learned whether its partner is
/// reachable: peer-first when the founder is up (join it), self-first when the founder is absent
/// (form alone — cold-start-alone preserved).
///
/// The analyzed role (must be applicable and NOT the founder).
/// Whether the partner's Akka endpoint became reachable within the probe window.
/// The seed order to pass to Cluster.JoinSeedNodes.
public static string[] HigherNodeOrder(BootstrapRole role, bool partnerReachable)
{
ArgumentNullException.ThrowIfNull(role);
return partnerReachable ? role.PeerFirstOrder : role.SelfFirstOrder;
}
/// Parses akka.tcp://system@host:port[/...] into host + port.
/// The seed URI.
/// The parsed advertised host.
/// The parsed Akka port.
/// True when the seed matched the expected shape.
public static bool TryParse(string? seed, out string host, out int port)
{
host = string.Empty;
port = 0;
if (string.IsNullOrWhiteSpace(seed)) return false;
var m = SeedPattern.Match(seed.Trim());
if (!m.Success) return false;
if (!int.TryParse(m.Groups["port"].Value, out port)) return false;
host = m.Groups["host"].Value;
return true;
}
private static string Key(string host, int port) => $"{host}:{port}";
}