From 2a7ff037181e2096ac1d46c91f6b16591894c1be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 13:37:21 -0400 Subject: [PATCH] feat: bridge ActorSystem into DI (transient) for shared health checks --- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 7 +++ .../SiteServiceRegistration.cs | 7 +++ .../ActorSystemBridgeTests.cs | 46 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 79a65f69..b1146c5f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -120,6 +120,13 @@ try builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem from DI. ScadaBridge owns the + // ActorSystem inside AkkaHostedService (not a DI singleton), so bridge it as TRANSIENT: each + // resolve re-reads the current value — null while warming up (checks → Degraded), live after. + // The factory must NOT throw: GetService() must return null (not raise) pre-start. + builder.Services.AddTransient(sp => + sp.GetRequiredService().ActorSystem!); + // InboundAPI-022: register the production IActiveNodeGate implementation so // standby-node gating is actually enforced (the InboundApiEndpointFilter // consults IActiveNodeGate and defaults to "allow" when none is registered, diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index c5b9cddc..05b5f7ab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -73,6 +73,13 @@ public static class SiteServiceRegistration services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); + // The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem from DI. ScadaBridge owns the + // ActorSystem inside AkkaHostedService (not a DI singleton), so bridge it as TRANSIENT: each + // resolve re-reads the current value — null while warming up (checks → Degraded), live after. + // The factory must NOT throw: GetService() must return null (not raise) pre-start. + services.AddTransient(sp => + sp.GetRequiredService().ActorSystem!); + // Cluster node status provider for health reports services.AddSingleton(sp => { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs new file mode 100644 index 00000000..6ca0145c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ActorSystemBridgeTests.cs @@ -0,0 +1,46 @@ +using Akka.Actor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Host; +using ZB.MOM.WW.ScadaBridge.Host.Actors; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Verifies the DI bridge that exposes the Akka — owned by +/// , not registered as a DI singleton — to consumers that +/// resolve ActorSystem from the container (notably the shared ZB.MOM.WW.Health Akka +/// checks). The bridge is registered TRANSIENT so each resolve re-reads the current value: +/// null while the hosted service is warming up (checks treat that as Degraded), the live +/// system afterwards. A SINGLETON would cache the startup-time null forever. +/// +public sealed class ActorSystemBridgeTests +{ + [Fact] + public void ActorSystem_ResolvesNull_BeforeHostedServiceStarts() + { + var services = new ServiceCollection(); + + // Register AkkaHostedService the same way Program.cs does, supplying the minimal + // constructor dependencies so the container can build it. Its ActorSystem property + // is null until StartAsync runs — which it never does here. + services.AddSingleton(Options.Create(new NodeOptions())); + services.AddSingleton(Options.Create(new ClusterOptions())); + services.AddSingleton(Options.Create(new CommunicationOptions())); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(); + + // The bridge under test: TRANSIENT factory that re-reads the owned ActorSystem. + services.AddTransient(sp => + sp.GetRequiredService().ActorSystem!); + + using var provider = services.BuildServiceProvider(); + + // The hosted service has not started, so the bridge must yield null (not throw). + Assert.Null(provider.GetService()); + } +}