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