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