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; } }