using Akka.Actor; using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.OtOpcUa.Cluster; /// /// Fail-fast startup validator for , built on the shared /// ZB.MOM.WW.Configuration and wired through /// AddValidatedOptions in . /// /// /// /// Self-first seed ordering (decision 2026-07-22). Akka runs /// FirstSeedNodeProcess — the only bootstrap path that can form a NEW cluster when no /// peer answers InitJoin — exclusively when seed-nodes[0] is this node's own /// address. Every other node runs JoinSeedNodeProcess and retries InitJoin /// forever. A node that lists its partner first therefore cannot cold-start while that /// partner is down, and the failure is silent: the process runs, the port listens, the /// node simply never becomes a member. Enforced here so it is loud, at boot, on the node /// that has the problem. /// /// /// The rule is conditional and must stay that way. It binds only when this node's own /// address appears in its own seed list. A driver-only site node is seeded solely by /// central-1 and is legitimately not a seed of anything; an unconditional "self must /// be first" rule would refuse to boot every one of them. /// /// /// Identity is the advertised address, never the bind address. In docker-dev /// Cluster:Hostname is 0.0.0.0 and Cluster:PublicHostname is the /// container DNS name — the value Akka puts in SelfAddress and the only one a seed URI /// can match. Comparing against Hostname would match nothing anywhere, silently exempt /// every node, and leave this rule inert. PublicHostname falling back to /// Hostname when blank mirrors Akka's own public-hostname semantics. /// /// public sealed class AkkaClusterOptionsValidator : OptionsValidatorBase { /// protected override void Validate(ValidationBuilder builder, AkkaClusterOptions options) { ArgumentNullException.ThrowIfNull(options); ValidateBootstrapGuard(builder, options.BootstrapGuard); var seeds = options.SeedNodes ?? Array.Empty(); if (seeds.Length == 0) { // Single-node / dev default: nothing is claimed about ordering, so there is nothing to // enforce. (Akka bootstraps from an empty seed list by waiting for an explicit join.) return; } var selfHost = string.IsNullOrWhiteSpace(options.PublicHostname) ? options.Hostname : options.PublicHostname; var selfIndex = Array.FindIndex(seeds, s => IsSelf(s, selfHost, options.Port)); if (selfIndex <= 0) { // -1 = this node is not one of its own seeds (exempt); 0 = correctly ordered. return; } builder.Add( $"Cluster:SeedNodes must list this node itself first: entry {selfIndex} is this node " + $"('{seeds[selfIndex]}') but entry 0 is '{seeds[0]}'. Akka only lets seed-nodes[0] form " + "a new cluster (FirstSeedNodeProcess); every other node retries InitJoin forever, so " + "with the partner listed first this node can never start while its peer is down. Swap " + "the entries — see docs/Redundancy.md → 'Bootstrap: self-first seed ordering'."); } /// /// When the bootstrap guard is enabled, its timing knobs must be positive. A zero/negative /// PartnerProbeSeconds in particular silently degrades the guard to "never wait, always /// conclude the partner is dead" — it would form alone immediately and re-open the very split it /// exists to close. Fail-fast at boot rather than producing silence (the same rationale every /// sibling options validator in this project cites). /// private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions guard) { if (guard is null || !guard.Enabled) return; if (guard.PartnerProbeSeconds <= 0) builder.Add($"Cluster:BootstrapGuard:PartnerProbeSeconds must be > 0 when the guard is enabled (was {guard.PartnerProbeSeconds})."); if (guard.PartnerProbeIntervalMs <= 0) builder.Add($"Cluster:BootstrapGuard:PartnerProbeIntervalMs must be > 0 when the guard is enabled (was {guard.PartnerProbeIntervalMs})."); if (guard.ProbeConnectTimeoutMs <= 0) builder.Add($"Cluster:BootstrapGuard:ProbeConnectTimeoutMs must be > 0 when the guard is enabled (was {guard.ProbeConnectTimeoutMs})."); } /// /// True when addresses this node itself — host AND port. /// /// /// Host comparison is case-insensitive because DNS names are, but is otherwise exact: Akka does /// no DNS canonicalisation either, so central-2 and central-2.example.com are /// genuinely different seed identities to the cluster. A seed entry Akka itself cannot parse is /// treated as "not this node" — reporting it is Akka's job, with Akka's wording, and this rule /// must not turn a parse problem into a misleading ordering complaint. /// private static bool IsSelf(string seed, string selfHost, int selfPort) { Address address; try { address = Address.Parse(seed); } catch (Exception) { return false; } return address.Port == selfPort && string.Equals(address.Host, selfHost, StringComparison.OrdinalIgnoreCase); } }