fix(health): site /health/active uses the shared role-scoped ActiveNodeHealthCheck (ScadaBridge#97)

Replace the private SitePairActiveNodeHealthCheck (503 whenever no ActorSystem, no data)
with the shared ZB.MOM.WW.Health ActiveNodeHealthCheck constructed with the site-{SiteId}
role: Degraded (200) during startup per the Health spec, and it publishes
selfAddress/activeRole/activeNode so the Overview dashboard can name the active site node.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:13:53 -05:00
parent a16b7b4e65
commit 7892c622cf
6 changed files with 85 additions and 85 deletions
@@ -118,7 +118,7 @@ public class SiteHealthCheckTests : IDisposable
[Theory]
[InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))]
[InlineData("localdb", typeof(SiteLocalDbHealthCheck))]
[InlineData("active-node", typeof(SitePairActiveNodeHealthCheck))]
[InlineData("active-node", typeof(ActiveNodeHealthCheck))]
public void Site_ChecksActivateToTheIntendedType(string name, Type expected)
{
var registration = Assert.Single(Registrations, r => r.Name == name);
@@ -130,20 +130,81 @@ public class SiteHealthCheckTests : IDisposable
Assert.IsType(expected, check);
}
/// <summary>Verifies that the site active-node check is role-scoped and is not the unscoped central check.</summary>
/// <summary>Verifies the site active-node check answers Degraded (HTTP 200), not Unhealthy, while the ActorSystem is not yet available (ScadaBridge#97).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck()
public async Task Site_ActiveNodeCheck_IsDegradedNotUnhealthy_DuringStartup()
{
// Central registers the shared ActiveNodeHealthCheck UNSCOPED — oldest Up member of the whole
// cluster. On a mesh carrying more than one site's members that computes "oldest" across the
// wrong member set, so a site node could report Active purely for being the oldest member
// overall. A site must answer for its own site-{SiteId} role, which SitePairActiveNodeHealthCheck
// does by delegating to the site's IClusterNodeProvider (itself now backed by the shared
// ClusterActiveNode, so the rule is shared even though the scoping is local).
var check = Assert.Single(Registrations, r => r.Name == "active-node").Factory(_host.Services);
// The Health spec says a node still booting is Degraded, not a failed standby. The old
// private site check returned Unhealthy (503) whenever no ActorSystem was up yet.
var registration = Assert.Single(Registrations, r => r.Name == "active-node");
using var empty = new ServiceCollection().BuildServiceProvider();
var check = registration.Factory(empty);
Assert.IsType<SitePairActiveNodeHealthCheck>(check);
Assert.IsNotType<ActiveNodeHealthCheck>(check);
var result = await check.CheckHealthAsync(NewContext("active-node", check));
Assert.Equal(HealthStatus.Degraded, result.Status);
}
/// <summary>Verifies the site active-node check reports the active node in its data and is scoped to the site's own role (ScadaBridge#97).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_ActiveNodeCheck_ReportsActiveNode_AndIsScopedToTheSiteRole()
{
var registration = Assert.Single(Registrations, r => r.Name == "active-node");
// A real single-node cluster that carries the site's role: it is the oldest Up member, so
// it is Active and must name itself as activeNode with activeRole = site-TestSite.
using var inRole = await StartSelfJoinedClusterAsync("site-TestSite");
var check = registration.Factory(inRole.Provider);
var result = await check.CheckHealthAsync(NewContext("active-node", check));
Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal("site-TestSite", result.Data["activeRole"]);
Assert.Equal(inRole.SelfAddress, result.Data["activeNode"]);
Assert.Equal(inRole.SelfAddress, result.Data["selfAddress"]);
// A node that is the oldest member overall but carries ANOTHER site's role is not this
// site's active node: the check is scoped, so it must not answer Healthy.
using var otherRole = await StartSelfJoinedClusterAsync("site-Other");
var other = registration.Factory(otherRole.Provider);
var otherResult = await other.CheckHealthAsync(NewContext("active-node", other));
Assert.NotEqual(HealthStatus.Healthy, otherResult.Status);
Assert.False(otherResult.Data.ContainsKey("activeNode"));
}
private static async Task<ClusterHandle> StartSelfJoinedClusterAsync(string role)
{
var config = Akka.Configuration.ConfigurationFactory.ParseString($$"""
akka.actor.provider = cluster
akka.remote.dot-netty.tcp.hostname = "127.0.0.1"
akka.remote.dot-netty.tcp.port = 0
akka.cluster.roles = ["{{role}}"]
akka.loglevel = OFF
""");
var system = Akka.Actor.ActorSystem.Create("hc" + Guid.NewGuid().ToString("N")[..8], config);
var cluster = Akka.Cluster.Cluster.Get(system);
cluster.Join(cluster.SelfAddress);
var deadline = DateTime.UtcNow.AddSeconds(20);
while (cluster.SelfMember.Status != Akka.Cluster.MemberStatus.Up && DateTime.UtcNow < deadline)
await Task.Delay(50);
Assert.Equal(Akka.Cluster.MemberStatus.Up, cluster.SelfMember.Status);
var services = new ServiceCollection();
services.AddSingleton(system);
return new ClusterHandle(system, services.BuildServiceProvider(), cluster.SelfAddress.ToString());
}
private sealed class ClusterHandle(Akka.Actor.ActorSystem system, ServiceProvider provider, string selfAddress) : IDisposable
{
public IServiceProvider Provider { get; } = provider;
public string SelfAddress { get; } = selfAddress;
public void Dispose()
{
system.Terminate().Wait(TimeSpan.FromSeconds(10));
}
}
/// <summary>Verifies that a site does not register central-only health checks.</summary>
@@ -204,38 +265,11 @@ public class SiteHealthCheckTests : IDisposable
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
/// <summary>Verifies that the active-node check separates the primary from the standby.</summary>
/// <param name="selfIsPrimary">Whether this node is the primary.</param>
/// <param name="expected">The health status the check is expected to report.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true, HealthStatus.Healthy)]
[InlineData(false, HealthStatus.Unhealthy)]
public async Task Site_ActiveNodeCheck_SplitsPrimaryFromStandby(bool selfIsPrimary, HealthStatus expected)
{
// 200 on the primary / 503 on the standby is the same contract central publishes, so a
// consumer reads one active-node meaning fleet-wide.
var check = new SitePairActiveNodeHealthCheck(new StubNodeProvider(selfIsPrimary));
var result = await check.CheckHealthAsync(NewContext("active-node", check));
Assert.Equal(expected, result.Status);
}
private static HealthCheckContext NewContext(string name, IHealthCheck check) => new()
{
Registration = new HealthCheckRegistration(name, check, HealthStatus.Unhealthy, tags: null),
};
private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider
{
/// <inheritdoc />
public bool SelfIsPrimary { get; } = selfIsPrimary;
/// <inheritdoc />
public IReadOnlyList<NodeStatus> GetClusterNodes() => [];
}
private sealed class ThrowingLocalDb : ILocalDb
{
/// <summary>Always throws to simulate an unreachable store.</summary>