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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user