From 6320be99540ede79b7b66715c2f4dc34766e916c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 8 Jul 2026 15:43:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(host):=20ClusterActivityEvaluator=20?= =?UTF-8?q?=E2=80=94=20oldest-member=20(singleton-host)=20active-node=20de?= =?UTF-8?q?finition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Health/ClusterActivityEvaluator.cs | 42 +++++++++++++++ .../ClusterActivityEvaluatorTests.cs | 54 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs new file mode 100644 index 00000000..30613b81 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs @@ -0,0 +1,42 @@ +using Akka.Cluster; + +namespace ZB.MOM.WW.ScadaBridge.Host.Health; + +/// +/// THE single definition of "active node" (review 01 [High]): a node is active +/// when it is the OLDEST Up member (optionally within a role scope) — i.e. the +/// member the ClusterSingletonManager places singletons on. Cluster LEADERSHIP +/// (lowest address) is an Akka-internal concept that diverges from singleton +/// placement permanently once the original first node restarts and rejoins; +/// every product-level active/standby decision must use this evaluator, never +/// cluster.State.Leader. +/// +public static class ClusterActivityEvaluator +{ + /// True when self is Up and no other Up member (in the role scope) is older. + public static bool SelfIsOldest(Cluster cluster, string? role = null) + { + var self = cluster.SelfMember; + if (self.Status != MemberStatus.Up) + return false; + if (role != null && !self.HasRole(role)) + return false; + + return cluster.State.Members + .Where(m => m.Status == MemberStatus.Up) + .Where(m => role == null || m.HasRole(role)) + .All(m => m.UniqueAddress.Equals(self.UniqueAddress) || self.IsOlderThan(m)); + } + + /// The oldest Up member in the role scope, or null while none is Up. Used for Primary/Standby labelling. + public static Member? OldestUpMember(Cluster cluster, string? role = null) + { + Member? oldest = null; + foreach (var m in cluster.State.Members.Where(m => m.Status == MemberStatus.Up)) + { + if (role != null && !m.HasRole(role)) continue; + if (oldest == null || m.IsOlderThan(oldest)) oldest = m; + } + return oldest; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs new file mode 100644 index 00000000..a1d34a86 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs @@ -0,0 +1,54 @@ +using Akka.Actor; +using Akka.Configuration; +using ZB.MOM.WW.ScadaBridge.Host.Health; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +public class ClusterActivityEvaluatorTests : IAsyncLifetime +{ + private ActorSystem? _system; + + public async Task InitializeAsync() + { + var port = FreePort(); + var config = ConfigurationFactory.ParseString($@" +akka {{ + actor.provider = cluster + remote.dot-netty.tcp {{ hostname = ""127.0.0.1"", port = {port} }} + cluster {{ + seed-nodes = [""akka.tcp://eval-test@127.0.0.1:{port}""] + roles = [""Central""] + min-nr-of-members = 1 + }} +}}"); + _system = ActorSystem.Create("eval-test", config); + var cluster = Akka.Cluster.Cluster.Get(_system); + var deadline = DateTime.UtcNow.AddSeconds(20); + while (cluster.SelfMember.Status != Akka.Cluster.MemberStatus.Up && DateTime.UtcNow < deadline) + await Task.Delay(100); + } + + public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); } + + [Fact] + public void SelfIsOldest_SoleUpMember_ReturnsTrue() + { + var cluster = Akka.Cluster.Cluster.Get(_system!); + Assert.True(ClusterActivityEvaluator.SelfIsOldest(cluster)); + Assert.True(ClusterActivityEvaluator.SelfIsOldest(cluster, "Central")); + } + + [Fact] + public void SelfIsOldest_RoleNotHeld_ReturnsFalse() + { + var cluster = Akka.Cluster.Cluster.Get(_system!); + // Self doesn't carry the role => it can never host that role's singletons. + Assert.False(ClusterActivityEvaluator.SelfIsOldest(cluster, "site-nonexistent")); + } + + private static int FreePort() + { + var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + l.Start(); var p = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return p; + } +}