using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace ZB.MOM.WW.Health.Akka;
///
/// Health check that maps the local node's Akka cluster membership status to a
/// through a configurable .
/// Register to the tag only — cluster membership is a readiness
/// concern; the tier is reserved for the leader / active-node probe.
///
///
/// 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 safe to register before Akka
/// is fully up.
///
public sealed class AkkaClusterHealthCheck : IHealthCheck
{
private readonly IServiceProvider _serviceProvider;
private readonly AkkaClusterStatusPolicy _policy;
/// Initializes a new .
///
/// The application service provider. The is resolved lazily so the
/// check is startup-safe: if no is registered yet the result is Degraded.
///
/// The status-to-health mapping policy to apply.
public AkkaClusterHealthCheck(IServiceProvider serviceProvider, AkkaClusterStatusPolicy policy)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
}
///
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."));
MemberStatus status;
IReadOnlyDictionary data;
try
{
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
// has not finished initialising (e.g. Akka.Cluster not yet configured →
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
// escaping exception (which the host would record as Unhealthy and pull the node from
// rotation).
var cluster = Cluster.Get(system);
status = cluster.SelfMember.Status;
data = BuildClusterData(cluster);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
}
var health = _policy.Evaluate(status);
var description = $"Akka cluster member status: {status}";
var result = health switch
{
HealthStatus.Healthy => HealthCheckResult.Healthy(description, data),
HealthStatus.Degraded => HealthCheckResult.Degraded(description, data: data),
_ => HealthCheckResult.Unhealthy(description, data: data),
};
return Task.FromResult(result);
}
///
/// Projects this node's view of the cluster into the check's data dictionary.
///
/// The cluster extension for the local .
///
/// JSON-friendly values only (string / int / string[]), so the canonical health writer can emit
/// them verbatim: leader (omitted while the leader is unknown, e.g. pre-join or during
/// convergence), selfAddress, selfRoles (sorted for a stable payload),
/// memberCount, unreachableCount.
///
///
/// This is deliberately each node's own view rather than an authoritative fleet answer —
/// a consumer that reads it from several members of the same cluster can compare them, and a
/// disagreement is a split-brain tell.
///
internal static IReadOnlyDictionary BuildClusterData(Cluster cluster)
{
ArgumentNullException.ThrowIfNull(cluster);
var state = cluster.State;
var data = new Dictionary(StringComparer.Ordinal)
{
["selfAddress"] = cluster.SelfAddress.ToString(),
["selfRoles"] = cluster.SelfMember.Roles.OrderBy(static r => r, StringComparer.Ordinal).ToArray(),
["memberCount"] = state.Members.Count,
["unreachableCount"] = state.Unreachable.Count,
};
// Omitted rather than emitted as null: a missing key reads as "this node does not know yet",
// which is exactly the pre-join / mid-convergence state.
var leader = state.Leader;
if (leader is not null)
data["leader"] = leader.ToString();
return data;
}
}