From 9ded86342e5d3f390054c5b503d37056659ea556 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 06:06:31 -0400 Subject: [PATCH] feat(cluster): InitJoin self-form fallback armed via Akka.Hosting startup task Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ClusterBootstrapFallback.cs | 106 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 17 +++ 2 files changed, 123 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapFallback.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapFallback.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapFallback.cs new file mode 100644 index 00000000..e3eeb76e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterBootstrapFallback.cs @@ -0,0 +1,106 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// InitJoin self-form fallback (decision 2026-07-22, scadaproj/akka_failover.md §6.1). +/// Akka only lets the FIRST listed seed form a NEW cluster; every other node retries InitJoin +/// forever. So "both nodes are seed nodes" () does NOT +/// mean either can cold-start alone — a non-first seed booting while its peer is down waits +/// indefinitely ("auto-down removes the crash outage, not this one" — docs/Redundancy.md). +/// This watchdog waits for membership; on expiry it +/// forms a cluster on itself. +/// +/// Island safety. Fires ONLY when this node's own address is in its own seed list. +/// A node that is not a seed (never legitimately first) must keep waiting: if it self-formed, a +/// later-booting real seed would form a second cluster and the two can never merge. That is the +/// current docker-dev site-node topology — site-a/site-b nodes list only central-1. For nodes that +/// ARE seeds, sequential recovery is island-free — Akka's join protocol prefers an existing cluster +/// (a booting node only self-forms when NO seed answers InitJoin), so a peer booting after this +/// node self-formed simply joins it. +/// +/// Races are benign. If the join completes between window expiry and +/// Cluster.Join(SelfAddress), Akka ignores the join — a node joins a cluster at most once +/// per incarnation. The residual risk is both pair nodes cold-starting inside the window while +/// mutually unreachable (a boot-time partition): both self-form, the same dual-active class the +/// auto-down downing strategy already accepts, with the same recovery (restart one side). +/// +/// Deliberately duplicated from ScadaBridge's equivalent (like the termination watchdogs) — +/// the two repos share the behavior, not a package. +/// +public static class ClusterBootstrapFallback +{ + /// + /// Arms the fallback on a freshly created actor system. Safe to call unconditionally: it no-ops + /// (with an explanatory log) when the fallback is disabled or when this node is not one of its + /// own seeds. + /// + /// The actor system whose cluster membership is being watched. + /// The bound cluster options carrying the window and the seed list. + /// Logger for the armed / inert / self-forming decisions. + public static void Arm(ActorSystem system, AkkaClusterOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(system); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + if (options.SelfFormAfter is not { } window || window <= TimeSpan.Zero) + { + logger.LogInformation( + "Cluster self-form fallback disabled (SelfFormAfter not set) — a node cold-starting " + + "while its peer is down will wait on InitJoin indefinitely."); + return; + } + + var cluster = Akka.Cluster.Cluster.Get(system); + var self = cluster.SelfAddress; + var isSeed = options.SeedNodes.Any(s => TryParseAddress(s, out var a) && a.Equals(self)); + if (!isSeed) + { + logger.LogInformation( + "Cluster self-form fallback inactive: this node ({Self}) is not in its own seed list " + + "[{Seeds}] — self-forming here would island it from the real seeds.", + self, + string.Join(", ", options.SeedNodes)); + return; + } + + var joined = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cluster.RegisterOnMemberUp(() => joined.TrySetResult()); + + _ = Task.Run(async () => + { + var winner = await Task.WhenAny(joined.Task, Task.Delay(window)); + if (winner == joined.Task || system.WhenTerminated.IsCompleted) + { + return; + } + + logger.LogWarning( + "No cluster membership after {Window} — no seed answered InitJoin (peer down at boot). " + + "Self-forming a cluster at {Self} so this node becomes operational; if the peer was " + + "merely partitioned (not dead), the pair is now dual-active — restart one side after " + + "the partition heals (accepted availability-first trade, decision 2026-07-22).", + window, + self); + cluster.Join(self); + }); + } + + private static bool TryParseAddress(string seed, out Address address) + { + try + { + address = Address.Parse(seed); + return true; + } + catch (Exception) + { + // A malformed seed entry must not disarm the fallback; bad seed URIs surface from + // Akka's own bootstrap. + address = Address.AllSystems; + return false; + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs index e8dfdcd4..f585c1b6 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -92,6 +92,23 @@ public static class ServiceCollectionExtensions // ActorSystem for exactly that reason: the effective value is the only one worth asserting. builder.AddHocon(BuildDowningHocon(options), HoconAddMode.Prepend); + // InitJoin self-form fallback (decision 2026-07-22): registered as an Akka.Hosting startup + // task so every host built through this bootstrap — production AND the test hosts in + // Cluster.Tests — arms it identically. Guarded inside Arm: disabled when SelfFormAfter is + // unset; inert when this node is not in its own seed list (site nodes seeded only by + // central-1 must wait, not island). + // ILoggerFactory is fully qualified: `using Microsoft.Extensions.Logging` would make the + // LogLevel in ConfigureLoggers above ambiguous with Akka.Event.LogLevel. + var fallbackLogger = + (serviceProvider.GetService() + ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) + .CreateLogger("ZB.MOM.WW.OtOpcUa.Cluster.ClusterBootstrapFallback"); + builder.AddStartup((system, _) => + { + ClusterBootstrapFallback.Arm(system, options, fallbackLogger); + return Task.CompletedTask; + }); + return builder; }