From cf277eb7df434ad56bf2980cf4aee07b36e8980a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 06:55:46 -0400 Subject: [PATCH] feat(health.akka): active/leader check with role filter + IActiveNodeGate impl --- .../ActiveNodeHealthCheck.cs | 138 ++++++++++++++++++ .../AkkaActiveNodeGate.cs | 50 +++++++ .../ActiveNodeDecisionTests.cs | 91 ++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaActiveNodeGate.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs new file mode 100644 index 0000000..97c1801 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/ActiveNodeHealthCheck.cs @@ -0,0 +1,138 @@ +using Akka.Actor; +using Akka.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ZB.MOM.WW.Health.Akka; + +/// +/// Pure decision function for the active / leader probe, factored out of +/// so the role-less and role-filtered matrices are exhaustively +/// table-testable without forming a real cluster. +/// +public static class ActiveNodeDecision +{ + /// + /// Maps the resolved cluster facts to a . + /// + /// Whether the local node's member status is Up. + /// + /// Whether the local node is the leader: the cluster leader in role-less mode, or the + /// role-singleton leader in role-filtered mode. + /// + /// + /// Whether the local node carries . Ignored when + /// is null. + /// + /// + /// The role to scope the check to, or null for the role-less (whole-cluster-leader) mode. + /// + /// + /// Role-less: Healthy iff the node is Up and the cluster leader, otherwise Unhealthy. + /// Role-filtered: Healthy when the node lacks the role (probe irrelevant) or carries the role and + /// is the role-singleton leader; Degraded when it carries the role but is not the leader. + /// + public static HealthStatus Evaluate(bool selfUp, bool isLeader, bool hasRole, string? requiredRole) + { + if (requiredRole is null) + return selfUp && isLeader ? HealthStatus.Healthy : HealthStatus.Unhealthy; + + if (!hasRole) + return HealthStatus.Healthy; + + return isLeader ? HealthStatus.Healthy : HealthStatus.Degraded; + } +} + +/// +/// Health check that reports whether this node is the designated active / leader node. +/// An optional role scopes the check to nodes carrying that role. Register to the +/// tag. +/// +/// +/// The is resolved lazily from the service provider. If it is not yet +/// available — e.g. during startup before Akka is initialised — the check returns +/// rather than throwing, so it is startup-safe. +/// +public sealed class ActiveNodeHealthCheck : IHealthCheck +{ + private readonly IServiceProvider _serviceProvider; + private readonly string? _role; + + /// + /// Role-less constructor: Healthy when the node is Up and the cluster leader + /// (ScadaBridge ActiveNode pattern); Unhealthy otherwise. Degraded when the ActorSystem / + /// cluster is not yet ready. + /// + /// + /// The application service provider. The is resolved lazily so the + /// check is startup-safe: if no is registered yet the result is Degraded. + /// + public ActiveNodeHealthCheck(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _role = null; + } + + /// + /// Role-filtered constructor: Healthy when the node lacks or carries it + /// and is the role-singleton leader; Degraded when it carries the role but is not the leader + /// (OtOpcUa AdminRoleLeader pattern). Degraded when the ActorSystem / cluster is not yet ready. + /// + /// + /// The application service provider. The is resolved lazily so the + /// check is startup-safe: if no is registered yet the result is Degraded. + /// + /// The Akka cluster role to scope the check to. + public ActiveNodeHealthCheck(IServiceProvider serviceProvider, string role) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _role = role ?? throw new ArgumentNullException(nameof(role)); + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var system = _serviceProvider.GetService(); + if (system is null) + return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available.")); + + var cluster = Cluster.Get(system); + var self = cluster.SelfMember; + var selfUp = self.Status == MemberStatus.Up; + + bool hasRole; + bool isLeader; + if (_role is null) + { + hasRole = false; + var leader = cluster.State.Leader; + isLeader = leader is not null && leader == self.Address; + } + else + { + hasRole = self.HasRole(_role); + var roleLeader = cluster.State.RoleLeader(_role); + isLeader = roleLeader is not null && roleLeader == self.Address; + } + + var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role); + return Task.FromResult(new HealthCheckResult(health, DescribeResult(health, self.Status))); + } + + private string DescribeResult(HealthStatus health, MemberStatus status) + { + if (_role is null) + return health == HealthStatus.Healthy + ? "Active node (cluster leader)." + : $"Standby node (status: {status})."; + + return health switch + { + HealthStatus.Healthy => $"Active for role '{_role}' (or not a role member).", + _ => $"Role '{_role}' member but not leader.", + }; + } +} diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaActiveNodeGate.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaActiveNodeGate.cs new file mode 100644 index 0000000..6a55fea --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaActiveNodeGate.cs @@ -0,0 +1,50 @@ +using Akka.Actor; +using Akka.Cluster; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.Health.Akka; + +/// +/// implementation that computes directly +/// from the Akka cluster state (self member Up and the local node is the cluster leader). +/// Register as a singleton. +/// +/// +/// The is resolved lazily from the service provider; if it is not yet +/// available — e.g. during startup before Akka is initialised — returns +/// false (the safe default during startup). This gate reads the cluster state directly and +/// does not resolve from DI. +/// +public sealed class AkkaActiveNodeGate : IActiveNodeGate +{ + private readonly IServiceProvider _serviceProvider; + + /// Initializes a new . + /// + /// The application service provider. The is resolved lazily; if it is + /// not yet available returns false. + /// + public AkkaActiveNodeGate(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public bool IsActiveNode + { + get + { + var system = _serviceProvider.GetService(); + if (system is null) + return false; + + var cluster = Cluster.Get(system); + var self = cluster.SelfMember; + if (self.Status != MemberStatus.Up) + return false; + + var leader = cluster.State.Leader; + return leader is not null && leader == self.Address; + } + } +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs new file mode 100644 index 0000000..1296376 --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ActiveNodeDecisionTests.cs @@ -0,0 +1,91 @@ +using Akka.Actor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.Health.Akka; + +namespace ZB.MOM.WW.Health.Akka.Tests; + +/// +/// Table-driven tests for the pure helper covering both +/// the role-less (ScadaBridge ActiveNode) and role-filtered (OtOpcUa AdminRoleLeader) matrices, +/// plus the startup-safety null-guards on and +/// when no is registered. +/// +public sealed class ActiveNodeDecisionTests +{ + // Role-less: requiredRole == null. hasRole is irrelevant. Healthy iff (selfUp && isLeader), else Unhealthy. + public static IEnumerable RoleLessCases() => new[] + { + new object[] { true, true, false, (string?)null, HealthStatus.Healthy }, + new object[] { true, false, false, (string?)null, HealthStatus.Unhealthy }, + new object[] { false, true, false, (string?)null, HealthStatus.Unhealthy }, + new object[] { false, false, false, (string?)null, HealthStatus.Unhealthy }, + }; + + [Theory] + [MemberData(nameof(RoleLessCases))] + public void Evaluate_RoleLess(bool selfUp, bool isLeader, bool hasRole, string? requiredRole, HealthStatus expected) + { + Assert.Equal(expected, ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, requiredRole)); + } + + // Role-filtered: requiredRole != null. + // lacks role -> Healthy (probe irrelevant for this node) + // has role & is leader -> Healthy + // has role & not leader -> Degraded + public static IEnumerable RoleFilteredCases() => new[] + { + // node lacks the role -> Healthy regardless of selfUp / isLeader + new object[] { true, true, false, "admin", HealthStatus.Healthy }, + new object[] { true, false, false, "admin", HealthStatus.Healthy }, + new object[] { false, false, false, "admin", HealthStatus.Healthy }, + // node carries the role and is leader -> Healthy + new object[] { true, true, true, "admin", HealthStatus.Healthy }, + // node carries the role but is not leader -> Degraded + new object[] { true, false, true, "admin", HealthStatus.Degraded }, + new object[] { false, false, true, "admin", HealthStatus.Degraded }, + }; + + [Theory] + [MemberData(nameof(RoleFilteredCases))] + public void Evaluate_RoleFiltered(bool selfUp, bool isLeader, bool hasRole, string? requiredRole, HealthStatus expected) + { + Assert.Equal(expected, ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, requiredRole)); + } + + [Fact] + public async Task HealthCheck_RoleLess_NoActorSystem_ReturnsDegraded() + { + var provider = new ServiceCollection().BuildServiceProvider(); + var check = new ActiveNodeHealthCheck(provider); + + var result = await check.CheckHealthAsync(NewContext(check)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + [Fact] + public async Task HealthCheck_RoleFiltered_NoActorSystem_ReturnsDegraded() + { + var provider = new ServiceCollection().BuildServiceProvider(); + var check = new ActiveNodeHealthCheck(provider, "admin"); + + var result = await check.CheckHealthAsync(NewContext(check)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + [Fact] + public void Gate_NoActorSystem_IsActiveNodeFalse() + { + var provider = new ServiceCollection().BuildServiceProvider(); + var gate = new AkkaActiveNodeGate(provider); + + Assert.False(gate.IsActiveNode); + } + + private static HealthCheckContext NewContext(IHealthCheck check) => new() + { + Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null), + }; +}