feat(health): serve MapZbHealth on site nodes
Site nodes served no health surface at all — gRPC on the HTTP/2-only listener and
/metrics on the HTTP/1.1 one — so nothing outside the cluster could ask a site
node whether it was ready or which half of the pair was active. The family
overview dashboard probes every instance the same way, and this is the one gap.
Three checks, registered in SiteServiceRegistration.Configure (not Program.cs, so
the composition-root tests that build this graph actually cover them) and mapped
by app.MapZbHealth() on the site's HTTP/1.1 listener (default :8084) alongside
/metrics:
akka-cluster [Ready] the shared AkkaClusterHealthCheck. Also carries the
cluster-view data (leader/memberCount/...) the dashboard
reads, free with ZB.MOM.WW.Health 0.2.0.
localdb [Ready] NEW SiteLocalDbHealthCheck — SELECT 1 through the
registered ILocalDb. Site has no EF context; central's
DatabaseHealthCheck<ScadaBridgeDbContext> is central-only.
Replication state rides along as data ENRICHMENT only:
replication is default-OFF, so failing on it would mark
every correctly-configured node unready.
active-node [Active] NEW SitePairActiveNodeHealthCheck, delegating to
IClusterNodeProvider.SelfIsPrimary. Deliberately NOT
central's OldestNodeActiveHealthCheck: that one calls
SelfIsOldest(cluster) with no role argument and would
compute "oldest" across the wrong member set on a mesh
carrying more than one site.
Anonymous, as central's are: the site pipeline runs no authentication middleware
and has no FallbackPolicy, so nothing extra was needed.
Also bumps ZB.MOM.WW.Health* 0.1.0 -> 0.2.0 (central gains data.leader for free).
Tests: SiteHealthCheckTests builds the REAL site container and activates every
registration through its factory (exact-set names, one tier tag each, resolved
types, central-only checks absent behind a positive control) plus behaviour for
both new checks. SiteHealthEndpointTests boots the real site Program over
WebApplicationFactory and proves the endpoints are MAPPED — without it, deleting
MapZbHealth would leave every registration test green while site nodes 404'd.
Prereq for scadaproj docs/plans/2026-07-22-overview-dashboard-impl-plan.md Phase 1.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.LocalDb.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// /health/ready check for the consolidated site database (LocalDb). Site nodes have no EF
|
||||
/// context — central's <c>DatabaseHealthCheck<ScadaBridgeDbContext></c> is central-only — so
|
||||
/// the site store is probed directly through the registered <see cref="ILocalDb"/> with a trivial
|
||||
/// <c>SELECT 1</c>. That is the honest readiness question for a site node: everything it persists
|
||||
/// (OperationTracking, site events, store-and-forward, the replicated config tables) lives here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Replication state is reported as <c>data</c> ENRICHMENT ONLY and never affects the status.
|
||||
/// Replication is default-OFF by design, so a node with no peer reports <c>connected=false</c> with
|
||||
/// a real 0 backlog — the healthy steady state of an unreplicated node — and failing the check on
|
||||
/// it would mark every correctly-configured single node unready.
|
||||
/// </remarks>
|
||||
public sealed class SiteLocalDbHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ILocalDb _localDb;
|
||||
private readonly ISyncStatus? _syncStatus;
|
||||
|
||||
/// <summary>Initializes a new <see cref="SiteLocalDbHealthCheck"/>.</summary>
|
||||
/// <param name="localDb">The consolidated site database registered by <c>AddZbLocalDb</c>.</param>
|
||||
/// <param name="syncStatus">
|
||||
/// The replication status view, when the replication engine is registered. Optional so the check
|
||||
/// still works in a composition that wires storage without replication.
|
||||
/// </param>
|
||||
public SiteLocalDbHealthCheck(ILocalDb localDb, ISyncStatus? syncStatus = null)
|
||||
{
|
||||
_localDb = localDb ?? throw new ArgumentNullException(nameof(localDb));
|
||||
_syncStatus = syncStatus;
|
||||
}
|
||||
|
||||
/// <summary>Probes the site database and reports its reachability.</summary>
|
||||
/// <param name="context">The health check context (unused; the verdict depends only on the probe).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Healthy when the probe query succeeds; Unhealthy with the fault otherwise.</returns>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _localDb.QueryAsync("SELECT 1;", static r => r.GetInt32(0), ct: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Site LocalDb is not reachable.", ex);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("Site LocalDb reachable.", BuildData());
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, object> BuildData()
|
||||
{
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
if (_syncStatus is null)
|
||||
return data;
|
||||
|
||||
data["replicationConnected"] = _syncStatus.Connected;
|
||||
|
||||
// Nullable on purpose upstream: null means "backlog unknown" (no provider wired or the poll
|
||||
// failed), which must not be flattened to 0 — that would render a pair which cannot read its
|
||||
// own oplog as perfectly caught up. Omit the key instead.
|
||||
if (_syncStatus.OplogBacklog is { } backlog)
|
||||
data["replicationOplogBacklog"] = backlog;
|
||||
|
||||
if (_syncStatus.PeerNodeId is { } peer)
|
||||
data["replicationPeerNodeId"] = peer;
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// /health/active check for a SITE pair: Healthy only on the pair's primary — the oldest Up member
|
||||
/// carrying the <c>site-{SiteId}</c> role. Same active/standby semantics central publishes, so a
|
||||
/// consumer reads one contract fleet-wide (200 = Active, 503 = Standby).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately NOT central's <see cref="OldestNodeActiveHealthCheck"/>: that one calls
|
||||
/// <c>ClusterActivityEvaluator.SelfIsOldest(cluster)</c> with no role argument, so on a mesh that
|
||||
/// carries more than one site's members it computes "oldest" across the wrong member set and a site
|
||||
/// node could report Active because it happens to be the oldest member overall. Delegating to
|
||||
/// <see cref="IClusterNodeProvider.SelfIsPrimary"/> reuses the role-scoped evaluation the site
|
||||
/// already trusts for singleton placement and event-log purge — one primary definition per site,
|
||||
/// not two that can disagree.
|
||||
/// </remarks>
|
||||
public sealed class SitePairActiveNodeHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IClusterNodeProvider _nodeProvider;
|
||||
|
||||
/// <summary>Initializes a new <see cref="SitePairActiveNodeHealthCheck"/>.</summary>
|
||||
/// <param name="nodeProvider">The role-scoped site cluster node provider.</param>
|
||||
public SitePairActiveNodeHealthCheck(IClusterNodeProvider nodeProvider) =>
|
||||
_nodeProvider = nodeProvider ?? throw new ArgumentNullException(nameof(nodeProvider));
|
||||
|
||||
/// <summary>Reports Healthy on the site pair's primary and Unhealthy on the standby.</summary>
|
||||
/// <param name="context">The health check context (unused; the verdict depends only on cluster membership).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Healthy on the primary; Unhealthy (503 = Standby) otherwise.</returns>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_nodeProvider.SelfIsPrimary
|
||||
? HealthCheckResult.Healthy("site pair primary (oldest Up member of the site role)")
|
||||
: HealthCheckResult.Unhealthy("not the site pair primary (standby)"));
|
||||
}
|
||||
Reference in New Issue
Block a user