using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; /// /// Validates at startup. The values it /// guards carry cluster-wide consequences — the design doc /// (Component-ClusterInfrastructure.md) is emphatic that misconfiguring /// them produces a total cluster shutdown or an indefinitely blocked singleton. /// Registered with ValidateOnStart() so a bad appsettings.json /// fails fast at boot rather than failing far from the cause. /// public sealed class ClusterOptionsValidator : OptionsValidatorBase { /// /// Downing strategies supported for ScadaBridge's two-node clusters. /// auto-down (default) survives a crash of either node at the accepted cost /// of dual-active during a real partition; keep-oldest is partition-safe but /// cannot survive a crash of the oldest node. Quorum strategies are rejected: /// static-quorum quorum-size 1 trips Akka's IsTooManyMembers guard (DownAll /// on any unreachability in a 2-node cluster) and keep-majority keys the /// fatal crash to the lowest-address node instead of the oldest. /// private static readonly HashSet AllowedStrategies = new(StringComparer.OrdinalIgnoreCase) { "auto-down", "keep-oldest" }; /// protected override void Validate(ValidationBuilder builder, ClusterOptions options) { ValidateBootstrapGuard(builder, options.BootstrapGuard); // The design doc states "both nodes are seed nodes — each node lists // both itself and its partner" so a properly-configured deployment lists // two. Accepting a single-seed configuration silently defeats the // "no startup ordering dependency" guarantee called out by // Component-ClusterInfrastructure.md (Node Configuration). var minSeeds = options.AllowSingleNodeCluster ? 1 : 2; builder.RequireThat(options.SeedNodes is not null && options.SeedNodes.Count >= minSeeds, options.AllowSingleNodeCluster ? "ClusterOptions.SeedNodes must contain at least 1 seed node." : "ClusterOptions.SeedNodes must contain at least 2 seed nodes " + "(Component-ClusterInfrastructure.md → Node Configuration: both nodes are seed nodes); " + "for a deliberate single-node install set ClusterOptions.AllowSingleNodeCluster = true instead of listing a phantom seed."); builder.RequireThat( !string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) && AllowedStrategies.Contains(options.SplitBrainResolverStrategy), $"ClusterOptions.SplitBrainResolverStrategy must be 'auto-down' or 'keep-oldest' for a " + $"two-node cluster; '{options.SplitBrainResolverStrategy}' would risk a total cluster " + "shutdown on a partition or an unreachability event."); builder.RequireThat(options.MinNrOfMembers == 1, $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " + "any other value blocks the cluster singleton after failover and halts all data collection."); builder.RequireThat(options.StableAfter > TimeSpan.Zero, "ClusterOptions.StableAfter must be a positive duration."); builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero, "ClusterOptions.HeartbeatInterval must be a positive duration."); builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero, "ClusterOptions.FailureDetectionThreshold must be a positive duration."); builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold, $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + "declared unreachable before a heartbeat can arrive."); // DownIfAlone is a keep-oldest knob; under auto-down each side downs the // unreachable peer regardless, so the flag is inert and any value is fine. var isKeepOldest = string.Equals( options.SplitBrainResolverStrategy, "keep-oldest", StringComparison.OrdinalIgnoreCase); builder.RequireThat(!isKeepOldest || options.DownIfAlone, "ClusterOptions.DownIfAlone must be true for the keep-oldest resolver " + "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the " + "oldest node can run as an isolated single-node cluster during a partition while the " + "younger node forms its own, producing two live clusters."); } /// /// When the bootstrap guard is enabled (Gitea #33), its timing knobs must be positive. A /// zero/negative in particular /// silently degrades the guard to "never wait, always conclude the partner is dead" — the /// higher node would form alone immediately and re-open the very split the guard exists to /// close. Fail fast at boot rather than producing that silent degradation. Nothing is checked /// when the guard is off (the knobs are inert), so a disabled guard never blocks a boot. /// private static void ValidateBootstrapGuard(ValidationBuilder builder, ClusterBootstrapGuardOptions? guard) { if (guard is null || !guard.Enabled) { return; } builder.RequireThat(guard.PartnerProbeSeconds > 0, $"ClusterOptions.BootstrapGuard.PartnerProbeSeconds must be > 0 when the guard is enabled " + $"(was {guard.PartnerProbeSeconds}); a non-positive value makes the higher node conclude its " + "partner is dead without waiting and form alone, re-opening the split the guard prevents."); builder.RequireThat(guard.PartnerProbeIntervalMs > 0, $"ClusterOptions.BootstrapGuard.PartnerProbeIntervalMs must be > 0 when the guard is enabled " + $"(was {guard.PartnerProbeIntervalMs})."); builder.RequireThat(guard.ProbeConnectTimeoutMs > 0, $"ClusterOptions.BootstrapGuard.ProbeConnectTimeoutMs must be > 0 when the guard is enabled " + $"(was {guard.ProbeConnectTimeoutMs})."); } }