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 index 97c1801..a9b54d3 100644 --- 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 @@ -1,8 +1,11 @@ +using System.Runtime.CompilerServices; using Akka.Actor; using Akka.Cluster; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +[assembly: InternalsVisibleTo("ZB.MOM.WW.Health.Akka.Tests")] + namespace ZB.MOM.WW.Health.Akka; /// @@ -10,7 +13,7 @@ namespace ZB.MOM.WW.Health.Akka; /// so the role-less and role-filtered matrices are exhaustively /// table-testable without forming a real cluster. /// -public static class ActiveNodeDecision +internal static class ActiveNodeDecision { /// /// Maps the resolved cluster facts to a . @@ -87,7 +90,8 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck public ActiveNodeHealthCheck(IServiceProvider serviceProvider, string role) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _role = role ?? throw new ArgumentNullException(nameof(role)); + ArgumentException.ThrowIfNullOrWhiteSpace(role); + _role = role; } /// @@ -119,15 +123,26 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck } var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role); - return Task.FromResult(new HealthCheckResult(health, DescribeResult(health, self.Status))); + var description = DescribeResult(health, self.Status, selfUp, isLeader); + var result = health switch + { + HealthStatus.Healthy => HealthCheckResult.Healthy(description), + HealthStatus.Degraded => HealthCheckResult.Degraded(description), + _ => HealthCheckResult.Unhealthy(description), + }; + return Task.FromResult(result); } - private string DescribeResult(HealthStatus health, MemberStatus status) + private string DescribeResult(HealthStatus health, MemberStatus status, bool selfUp, bool isLeader) { if (_role is null) - return health == HealthStatus.Healthy - ? "Active node (cluster leader)." - : $"Standby node (status: {status})."; + { + if (health == HealthStatus.Healthy) + return "Active node (cluster leader)."; + return selfUp && !isLeader + ? "Standby: node is Up but not the cluster leader." + : $"Standby: node is not Up (status: {status})."; + } return health switch { diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs index 3deab3c..74e040b 100644 --- a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health.Akka/AkkaClusterHealthCheck.cs @@ -45,7 +45,12 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck var status = Cluster.Get(system).SelfMember.Status; var health = _policy.Evaluate(status); var description = $"Akka cluster member status: {status}"; - - return Task.FromResult(new HealthCheckResult(health, description)); + var result = health switch + { + HealthStatus.Healthy => HealthCheckResult.Healthy(description), + HealthStatus.Degraded => HealthCheckResult.Degraded(description), + _ => HealthCheckResult.Unhealthy(description), + }; + return Task.FromResult(result); } } 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 index 1296376..7659ba0 100644 --- 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 @@ -31,7 +31,7 @@ public sealed class ActiveNodeDecisionTests // Role-filtered: requiredRole != null. // lacks role -> Healthy (probe irrelevant for this node) - // has role & is leader -> Healthy + // has role & is leader -> Healthy (selfUp is ignored — role-filtered mode only cares about leadership) // has role & not leader -> Degraded public static IEnumerable RoleFilteredCases() => new[] { @@ -39,8 +39,10 @@ public sealed class ActiveNodeDecisionTests 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 + // node carries the role and is leader -> Healthy (selfUp=true) new object[] { true, true, true, "admin", HealthStatus.Healthy }, + // node carries the role and is leader -> Healthy (selfUp=false: role-filtered mode ignores selfUp) + new object[] { false, 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 }, diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs index 3e2d347..406ac47 100644 --- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/AkkaClusterStatusPolicyTests.cs @@ -23,6 +23,7 @@ public sealed class AkkaClusterStatusPolicyTests new object[] { MemberStatus.WeaklyUp, HealthStatus.Unhealthy }, new object[] { MemberStatus.Down, HealthStatus.Unhealthy }, new object[] { MemberStatus.Removed, HealthStatus.Unhealthy }, + new object[] { (MemberStatus)99, HealthStatus.Unhealthy }, // unknown / future status }; [Theory] @@ -41,6 +42,7 @@ public sealed class AkkaClusterStatusPolicyTests new object[] { MemberStatus.WeaklyUp, HealthStatus.Degraded }, new object[] { MemberStatus.Down, HealthStatus.Degraded }, new object[] { MemberStatus.Removed, HealthStatus.Degraded }, + new object[] { (MemberStatus)99, HealthStatus.Degraded }, // unknown / future status }; [Theory] diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ZB.MOM.WW.Health.Akka.Tests.csproj b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ZB.MOM.WW.Health.Akka.Tests.csproj index 615803f..a858d74 100644 --- a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ZB.MOM.WW.Health.Akka.Tests.csproj +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Akka.Tests/ZB.MOM.WW.Health.Akka.Tests.csproj @@ -9,7 +9,6 @@ - diff --git a/components/health/spec/SPEC.md b/components/health/spec/SPEC.md index b956c04..6612904 100644 --- a/components/health/spec/SPEC.md +++ b/components/health/spec/SPEC.md @@ -150,7 +150,10 @@ plug it into `MapHealthChecks` options and also call it from custom endpoints. `IActiveNodeGate` is a single-property interface (`bool IsActiveNode { get; }`) that expresses whether the current node should accept write / active-role requests. The default implementation, -`AkkaActiveNodeGate`, delegates to `ActiveNodeHealthCheck`. A `RequireActiveNode()` extension on +`AkkaActiveNodeGate`, reads cluster state **directly**: `IsActiveNode` returns `true` iff the +`ActorSystem` is available, `SelfMember.Status == Up`, and the node is the cluster leader. It is +null-guarded and returns `false` when the `ActorSystem` is not yet ready (safe default during +startup). It does **not** resolve `ActiveNodeHealthCheck` from DI. A `RequireActiveNode()` extension on `IEndpointConventionBuilder` attaches a policy that short-circuits with `503 Service Unavailable` on standby nodes.