refactor(health.akka): review polish (internal decision helper, role guard, factory results, test coverage) + fix SPEC §4 gate description

This commit is contained in:
Joseph Doherty
2026-06-01 07:04:29 -04:00
parent edbc79204f
commit 1c2b23cbbb
6 changed files with 39 additions and 13 deletions
@@ -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;
/// <summary>
@@ -10,7 +13,7 @@ namespace ZB.MOM.WW.Health.Akka;
/// <see cref="ActiveNodeHealthCheck"/> so the role-less and role-filtered matrices are exhaustively
/// table-testable without forming a real cluster.
/// </summary>
public static class ActiveNodeDecision
internal static class ActiveNodeDecision
{
/// <summary>
/// Maps the resolved cluster facts to a <see cref="HealthStatus"/>.
@@ -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;
}
/// <inheritdoc />
@@ -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
{
@@ -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);
}
}
@@ -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<object[]> 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 },
@@ -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]
@@ -9,7 +9,6 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Akka.TestKit.Xunit2" />
</ItemGroup>
<ItemGroup>
+4 -1
View File
@@ -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.