From 09d6676e1fbcc1ebcec37c46027934b59957e57c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 06:09:37 -0400 Subject: [PATCH] feat(runtime): WithOtOpcUaRuntimeActors extension for driver-role node startup (F19) Mirrors WithOtOpcUaControlPlaneSingletons for the driver role. Spawns DriverHostActor + DbHealthProbeActor on the host's ActorSystem and registers both under marker keys. Host's Program.cs now calls it when the node carries the driver role, so driver-only and admin+driver deployments both auto-bootstrap the per-node actors. Integration test covers the registration round-trip via Microsoft.Extensions.Hosting + Akka.Hosting AddAkka. --- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 7 +- .../ServiceCollectionExtensions.cs | 57 +++++++++++++ .../ServiceCollectionExtensionsTests.cs | 85 +++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 9730780..a7e0342 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host.Health; +using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Security; using ZB.MOM.WW.OtOpcUa.Security.Endpoints; @@ -43,10 +44,8 @@ builder.Services.AddAkka("otopcua", (ab, _) => { if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); - // Driver-role startup (DriverHostActor spawn + child probes) is wired in F19 once a - // RuntimeStartup contract is added — the actor itself exists (Phase 6), the registration - // extension does not yet. Without it, driver-role nodes still join the cluster and serve - // health/redundancy traffic but won't auto-spawn DriverHostActor. + if (hasDriver) + ab.WithOtOpcUaRuntimeActors(); }); if (hasAdmin) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..bf410b8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Akka.Actor; +using Akka.Hosting; +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Health; + +namespace ZB.MOM.WW.OtOpcUa.Runtime; + +public static class ServiceCollectionExtensions +{ + public const string DriverRole = "driver"; + + public const string DriverHostActorName = "driver-host"; + public const string DbHealthProbeActorName = "db-health"; + + /// + /// Spawns the per-node driver-role actors on the host's : + /// (one per node) and + /// (consumed by the health endpoint + redundancy calc). + /// + /// Mirror of WithOtOpcUaControlPlaneSingletons for the driver role. Both must + /// be registered on the same as the cluster + /// bootstrap so the actors share the host's ActorSystem. + /// + /// Wire from the fused Host's Program.cs when the node carries the driver role: + /// + /// if (hasDriver) + /// ab.WithOtOpcUaRuntimeActors(); + /// + /// + public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder) + { + builder.WithActors((system, registry, resolver) => + { + var dbFactory = resolver.GetService>(); + var roleInfo = resolver.GetService(); + + var dbHealth = system.ActorOf( + DbHealthProbeActor.Props(dbFactory), + DbHealthProbeActorName); + registry.Register(dbHealth); + + var driverHost = system.ActorOf( + DriverHostActor.Props(dbFactory, roleInfo.LocalNode), + DriverHostActorName); + registry.Register(driverHost); + }); + + return builder; + } +} + +/// Marker key types used by Akka.Hosting to resolve runtime actors from the registry. +public sealed class DriverHostActorKey { } +public sealed class DbHealthProbeActorKey { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..33907eb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,85 @@ +using Akka.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests; + +/// +/// Verifies WithOtOpcUaRuntimeActors spawns DriverHostActor + DbHealthProbeActor +/// on the host's ActorSystem and registers both under their marker keys. This is the +/// driver-role mirror of the admin-role WithOtOpcUaControlPlaneSingletons bootstrap. +/// +public sealed class ServiceCollectionExtensionsTests +{ + [Fact] + public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe() + { + using var host = Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddSingleton>( + new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N"))); + services.AddSingleton(new FakeClusterRoleInfo()); + + services.AddAkka("otopcua-test", (ab, _) => + { + ab.AddHocon(@" + akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" + akka.remote.dot-netty.tcp.hostname = ""127.0.0.1"" + akka.remote.dot-netty.tcp.port = 0 + akka.cluster.seed-nodes = [] + akka.cluster.roles = [""driver""] + ", HoconAddMode.Prepend); + ab.WithOtOpcUaRuntimeActors(); + }); + }) + .Build(); + + await host.StartAsync(); + try + { + var driverHost = host.Services.GetRequiredService>(); + var dbHealth = host.Services.GetRequiredService>(); + + driverHost.ActorRef.ShouldNotBeNull(); + dbHealth.ActorRef.ShouldNotBeNull(); + driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName); + dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName); + } + finally + { + await host.StopAsync(); + } + } + + private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new OtOpcUaConfigDbContext(opts); + } + } + + private sealed class FakeClusterRoleInfo : IClusterRoleInfo + { + public NodeId LocalNode { get; } = NodeId.Parse("test-node"); + public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]); + public bool HasRole(string role) => LocalRoles.Contains(role); + public IReadOnlyList MembersWithRole(string role) => Array.Empty(); + public NodeId? RoleLeader(string role) => null; + public event EventHandler? RoleLeaderChanged + { + add { _ = value; } + remove { _ = value; } + } + } +}