refactor(inbound-api): use the shared ZB.MOM.WW.Health IActiveNodeGate (ScadaBridge#80)

The InboundAPI carried its own IActiveNodeGate (implemented by Host/Health/ActiveNodeGate)
whose rule already matched the shared Health 0.3.0 AkkaActiveNodeGate — both route to
ClusterActiveNode (oldest Up member). The private interface and implementation are deleted;
InboundAPI references ZB.MOM.WW.Health (0.3.0, already pinned) and InboundApiEndpointFilter,
AuditWriteMiddleware, InboundMethodWarmupService and FlowDebugRunner consume
ZB.MOM.WW.Health.IActiveNodeGate. The Central branch of Program.cs registers
AkkaActiveNodeGate (unscoped, like the active-node health check).

Warm-up behaviour is kept: the shared gate answers false while no ActorSystem resolves, while
this member is not Up, and on any cluster-init fault. It resolves the ActorSystem through the
existing DI singleton bridge (GetOrCreateActorSystem), the same path the health probes use.

Tests: CompositionRoot asserts the shared gate type (fails with the registration removed);
the evaluator wiring test now drives AkkaActiveNodeGate; new warm-up test (no ActorSystem ->
not active). Nothing was missing from the Health 0.3.0 package.

(ScadaBridge#80)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:39:54 -05:00
parent 1ee2e171cf
commit 23c35df234
18 changed files with 52 additions and 91 deletions
@@ -93,7 +93,12 @@ akka {{
while (cluster.SelfMember.Status != Akka.Cluster.MemberStatus.Up && DateTime.UtcNow < deadline)
await Task.Delay(100);
var gate = new ActiveNodeGate(akkaService);
// Gitea #80: the inbound API's gate is the shared AkkaActiveNodeGate, resolving the
// ActorSystem from DI exactly as Program.cs's singleton bridge serves it.
using var sp = new ServiceCollection()
.AddSingleton(_ => akkaService.GetOrCreateActorSystem())
.BuildServiceProvider();
var gate = new ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate(sp);
var provider = new AkkaClusterNodeProvider(akkaService, "Central");
Assert.True(gate.IsActiveNode);
Assert.True(provider.SelfIsPrimary);
@@ -104,6 +109,17 @@ akka {{
}
}
/// <summary>
/// Gitea #80 warm-up contract: while no ActorSystem is available the shared gate reports
/// standby, never active — the safe default the retired ScadaBridge gate also had.
/// </summary>
[Fact]
public void ActiveNodeGate_WithoutActorSystem_IsNotActive()
{
using var sp = new ServiceCollection().BuildServiceProvider();
Assert.False(new ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate(sp).IsActiveNode);
}
private static int FreePort()
{
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
@@ -332,7 +332,7 @@ public class CentralCompositionRootTests : IDisposable
/// <summary>
/// Regression test: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// concrete <see cref="ZB.MOM.WW.Health.IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
/// active node and executing scripts against stale singleton state. The
@@ -344,12 +344,17 @@ public class CentralCompositionRootTests : IDisposable
/// indistinguishable from "missing" at runtime, which is the failure mode
/// the finding describes.
/// </summary>
/// <remarks>
/// Gitea #80: the gate is the shared <see cref="ZB.MOM.WW.Health.IActiveNodeGate"/> backed by
/// <see cref="ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate"/> — the same ClusterActiveNode rule as
/// the /health/active probe — not a ScadaBridge-private seam.
/// </remarks>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<IActiveNodeGate>();
var gate = _factory.Services.GetService<ZB.MOM.WW.Health.IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ActiveNodeGate>(gate);
Assert.IsType<ZB.MOM.WW.Health.Akka.AkkaActiveNodeGate>(gate);
}
// --- ScadaBridge#22 regression ---
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -18,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.ManagementService.FlowDebug;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;