namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; /// /// Cluster configuration model, bound from the ScadaBridge:Cluster section /// of appsettings.json via the Options pattern. /// /// This project owns the cluster configuration contract. The actual /// Akka.NET bootstrap — building the HOCON from these values, starting the /// ActorSystem, configuring the split-brain resolver and wiring /// CoordinatedShutdown — lives in ZB.MOM.WW.ScadaBridge.Host /// (see Component-ClusterInfrastructure.md → "Implementation Note — Code Placement"). /// /// /// Node-identity settings (remoting hostname/port, cluster role, site identifier, /// gRPC port) are deliberately not here — they are owned by /// ZB.MOM.WW.ScadaBridge.Host.NodeOptions (ScadaBridge:Node section). Local SQLite /// storage paths are owned by the database / store-and-forward options. This class /// holds only the cluster-formation and failure-detection settings shared by every node. /// /// public class ClusterOptions { // The previous `public const string SectionName = "ScadaBridge:Cluster";` // was documented as "single source of truth so binding sites do not hard-code the // magic string" but no caller ever read it — the Host's SiteServiceRegistration and // StartupValidator both hard-code the literal directly. Wiring those binding sites // to reference the constant lives in the Host's edit scope (a separate code-review // task); rather than carry a public constant whose guarantee the code does not // deliver, the constant is removed and the literal stays in the Host until the // Host-side wiring is done. If a future Host change wants the constant back, add it // when the binding sites can be updated in the same commit. /// /// Akka.NET cluster seed nodes. Both nodes are seed nodes — each node lists itself and its /// partner. /// /// ORDER IS LOAD-BEARING (decision 2026-07-22): every node must list ITSELF first. /// 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; any other node runs JoinSeedNodeProcess and retries /// InitJoin forever. So merely listing both nodes does NOT mean either can start /// first: a node that lists its partner first can never cold-start while that partner is /// down (the "registered outage gap", docker/README.md). Self-first ordering closes /// it using Akka's own protocol, which — unlike an external self-form timer — is part of /// the join handshake and so cannot mistake an in-flight join for an absent peer. /// Enforced at boot by StartupValidator. /// /// Must contain at least one entry. /// public List SeedNodes { get; set; } = new(); /// /// Downing strategy for unreachable members. Two supported values: /// /// auto-down (default, decision 2026-07-21) — availability-first: each /// side downs the unreachable peer after , so a hard crash /// of EITHER node (oldest included) fails over to the survivor. The accepted trade: /// a true network partition produces two live one-node clusters (dual-active) until /// an operator restarts one side. Chosen because ScadaBridge pairs run one node per /// VM with no shared lease infrastructure, and a stalled system is a bigger risk /// than a rare partition. /// keep-oldest — partition-safe SBR: downs the side without the oldest /// member. In a TWO-node cluster this makes a crash of the oldest/active node a /// total outage: Akka's down-if-alone only rescues the survivor when its own /// side has ≥2 members (verified against Akka.NET 1.5.62 KeepOldest.Decide /// and live on the docker rig, 2026-07-21). /// /// Other SBR strategies are rejected: static-quorum with quorum 1 hits Akka's /// IsTooManyMembers guard (2 > 2*1-1) and downs ALL on any unreachability; /// keep-majority just moves the fatal crash from the oldest to the /// lowest-address node. /// public string SplitBrainResolverStrategy { get; set; } = "auto-down"; /// /// Time the cluster membership must remain stable before the split-brain /// resolver acts to down unreachable nodes. Must be positive. Default 15s. /// public TimeSpan StableAfter { get; set; } = TimeSpan.FromSeconds(15); /// /// Frequency of cluster failure-detector heartbeat messages between nodes. /// Must be well below . Default 2s. /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); /// /// Time without a heartbeat before a node is considered unreachable /// (Akka's acceptable-heartbeat-pause). Default 10s. /// public TimeSpan FailureDetectionThreshold { get; set; } = TimeSpan.FromSeconds(10); /// /// Akka's min-nr-of-members. Must be 1: after failover only one /// node runs, and a value of 2 blocks the cluster singleton (Site Runtime /// Deployment Manager) — and therefore all data collection — indefinitely. /// public int MinNrOfMembers { get; set; } = 1; /// /// The keep-oldest resolver's down-if-alone flag; only consulted when /// is keep-oldest. When true, /// the oldest node downs itself if it finds it has no other reachable members, /// rather than running as an isolated single-node cluster. Note that in a two-node /// cluster this does NOT let the younger survivor take over from a crashed oldest — /// Akka's alone-check requires the surviving side to have ≥2 members. /// public bool DownIfAlone { get; set; } = true; /// /// Acknowledges a deliberate single-node deployment: permits exactly one seed /// node (normally ≥2 are required so either node can start first). Without this /// flag a single-node install must list a phantom second seed, which the node /// dials forever — log noise and a defeated validation intent (review 01). /// public bool AllowSingleNodeCluster { get; set; } /// /// The simultaneous-cold-start split-brain guard (ScadaBridge:Cluster:BootstrapGuard). /// Default OFF — a dark switch, so existing deployments and tests keep Akka's config-driven /// self-first auto-join byte-identical. See for the /// decision logic and ClusterBootstrapCoordinator for the runtime (Gitea #33). /// public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new(); } /// /// Configuration for the simultaneous-cold-start split-brain guard. Both nodes of a pair are /// self-first seeds so either can cold-start alone when its partner is dead — but 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 that does /// not auto-merge). When , the node does NOT auto-join from its config seeds /// (AkkaHostedService.BuildHocon emits an empty seed list); a coordinator picks the join /// order — founder self-first / joiner peer-first — after a reachability probe. See /// . /// public sealed class ClusterBootstrapGuardOptions { /// /// Whether the guard is active. Default — Akka auto-joins from the /// config seed list exactly as before. Only meaningful on a node that is one of its own two /// pair seeds; inert everywhere else (single-seed site node, self absent, 3+ seeds). /// public bool Enabled { get; set; } /// /// How long the higher-address node probes its partner's Akka endpoint before concluding the /// partner is dead and forming alone. Must comfortably exceed the partner's worst-case /// process-start-to-Akka-bind time so a slow-but-alive partner is never mistaken for a dead one /// (which would re-open the split). Default 25s. Validated > 0 at boot when the guard is on. /// public int PartnerProbeSeconds { get; set; } = 25; /// /// The interval between partner reachability probes, in milliseconds. Default 500ms. /// public int PartnerProbeIntervalMs { get; set; } = 500; /// /// The per-probe TCP connect timeout, in milliseconds. Default 1000ms. /// public int ProbeConnectTimeoutMs { get; set; } = 1000; }