80668a07bd
Phase 0 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md: give the
canonical health JSON a structured channel so the family overview dashboard can
read each Akka cluster's current leader.
- ZbHealthWriter: optional `"data": {...}` per entry, sourced from
HealthReportEntry.Data, emitted only when non-empty. Per-property JsonIgnore
(NOT a global DefaultIgnoreCondition) so `"description": null` still renders —
payloads from data-less checks stay byte-identical to 0.1.0.
- AkkaClusterHealthCheck: BuildClusterData publishes this node's own view —
leader (omitted while unknown), selfAddress, selfRoles (sorted), memberCount,
unreachableCount — on every result path. The startup-safety paths (no
ActorSystem / cluster inaccessible) stay description-only.
- Tests: writer data emit/omit (raw-JSON assert on the omit case), and a real
single-node self-joined cluster via Akka.TestKit.Xunit2 for the data values.
70 tests green (25/39/6).
- Version 0.1.0 -> 0.2.0; 3 packages published to the Gitea feed and
restore-verified from a scratch consumer, which serves data.leader live.
112 lines
5.2 KiB
C#
112 lines
5.2 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
|
|
namespace ZB.MOM.WW.Health.Akka;
|
|
|
|
/// <summary>
|
|
/// Health check that maps the local node's Akka cluster membership status to a
|
|
/// <see cref="HealthStatus"/> through a configurable <see cref="AkkaClusterStatusPolicy"/>.
|
|
/// Register to the <see cref="ZbHealthTags.Ready"/> tag only — cluster membership is a readiness
|
|
/// concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for the leader / active-node probe.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider. If it is not yet
|
|
/// available — e.g. during startup before Akka is initialised — the check returns
|
|
/// <see cref="HealthStatus.Degraded"/> rather than throwing, so it is safe to register before Akka
|
|
/// is fully up.
|
|
/// </remarks>
|
|
public sealed class AkkaClusterHealthCheck : IHealthCheck
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly AkkaClusterStatusPolicy _policy;
|
|
|
|
/// <summary>Initializes a new <see cref="AkkaClusterHealthCheck"/>.</summary>
|
|
/// <param name="serviceProvider">
|
|
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
|
|
/// check is startup-safe: if no <see cref="ActorSystem"/> is registered yet the result is Degraded.
|
|
/// </param>
|
|
/// <param name="policy">The status-to-health mapping policy to apply.</param>
|
|
public AkkaClusterHealthCheck(IServiceProvider serviceProvider, AkkaClusterStatusPolicy policy)
|
|
{
|
|
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
|
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<HealthCheckResult> CheckHealthAsync(
|
|
HealthCheckContext context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var system = _serviceProvider.GetService<ActorSystem>();
|
|
if (system is null)
|
|
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
|
|
|
MemberStatus status;
|
|
IReadOnlyDictionary<string, object> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects this node's view of the cluster into the check's <c>data</c> dictionary.
|
|
/// </summary>
|
|
/// <param name="cluster">The cluster extension for the local <see cref="ActorSystem"/>.</param>
|
|
/// <returns>
|
|
/// JSON-friendly values only (string / int / string[]), so the canonical health writer can emit
|
|
/// them verbatim: <c>leader</c> (omitted while the leader is unknown, e.g. pre-join or during
|
|
/// convergence), <c>selfAddress</c>, <c>selfRoles</c> (sorted for a stable payload),
|
|
/// <c>memberCount</c>, <c>unreachableCount</c>.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// This is deliberately each node's <em>own</em> 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.
|
|
/// </remarks>
|
|
internal static IReadOnlyDictionary<string, object> BuildClusterData(Cluster cluster)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(cluster);
|
|
|
|
var state = cluster.State;
|
|
var data = new Dictionary<string, object>(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;
|
|
}
|
|
}
|