diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs index 86788b02..81a2f973 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs @@ -189,9 +189,9 @@ public static class ServiceCollectionExtensions /// Primary and publishing redundancy-state on its own mesh's DistributedPubSub. /// public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton( - this AkkaConfigurationBuilder builder, IClusterRoleInfo roleInfo) + this AkkaConfigurationBuilder builder, AkkaClusterOptions clusterOptions) { - var singletonOptions = BuildClusterRedundancySingletonOptions(roleInfo); + var singletonOptions = BuildClusterRedundancySingletonOptions(clusterOptions); builder.WithSingleton( RedundancyStateSingletonName, @@ -216,13 +216,23 @@ public static class ServiceCollectionExtensions /// (b) on a genuinely split 2-node mesh the driver role is already pair-local — the mesh IS /// the pair. So the fallback is correct in both worlds and costs no availability. /// - /// The local node's cluster-role view. + /// The node's own cluster configuration (roles), read without the + /// ActorSystem so this is safe to call inside the Akka configurator lambda. /// Singleton options scoped to the node's cluster role, or the driver role. - public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(IClusterRoleInfo roleInfo) + public static ClusterSingletonOptions BuildClusterRedundancySingletonOptions(AkkaClusterOptions clusterOptions) { - ArgumentNullException.ThrowIfNull(roleInfo); + ArgumentNullException.ThrowIfNull(clusterOptions); - return new ClusterSingletonOptions { Role = roleInfo.ClusterRole ?? RoleParser.Driver }; + // Derive the cluster-{ClusterId} scope from the node's OWN configured roles — NOT from + // IClusterRoleInfo. IClusterRoleInfo's implementation depends on the ActorSystem (it is a live + // Cluster.State view), and this runs inside the AddAkka configurator lambda WHILE the + // ActorSystem is being built; resolving IClusterRoleInfo there recurses back into ActorSystem + // construction and stack-overflows the host at boot (the Phase 6 live gate caught exactly this + // on the fused central node). AkkaClusterOptions is pure configuration, available before the + // cluster forms — the same source ClusterRoleInfo.ClusterRole itself derives from — so the + // scope is identical. First cluster role wins, in configuration order (matching ClusterRoleInfo). + var clusterRole = clusterOptions.Roles.FirstOrDefault(RoleParser.IsClusterRole); + return new ClusterSingletonOptions { Role = clusterRole ?? RoleParser.Driver }; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 0844e115..8f133bfb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Serilog; using ZB.MOM.WW.LocalDb.Replication; using Serilog.Events; @@ -380,9 +381,12 @@ builder.Services.AddAkka("otopcua", (ab, sp) => // back to the driver role for legacy single-mesh / not-yet-migrated nodes (already pair-local // on a split mesh, since the mesh IS the pair). Runs on the fused central node too (it is // hasDriver), where it scopes to cluster-MAIN — exactly one redundancy singleton per node, - // since the admin branch above no longer registers one. IClusterRoleInfo is registered by - // AddOtOpcUaCluster and resolved lazily here. - ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService()); + // since the admin branch above no longer registers one. Pass AkkaClusterOptions (pure config), + // NOT IClusterRoleInfo: this lambda runs WHILE the ActorSystem is being built, and + // IClusterRoleInfo depends on the ActorSystem — resolving it here recurses into ActorSystem + // construction and stack-overflows the host at boot. The singleton scope only needs the node's + // configured cluster role, which AkkaClusterOptions carries. + ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService>().Value); } }); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs index f92133d4..1e969cc4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs @@ -36,7 +36,7 @@ public sealed class RedundancyStateSingletonRehomeTests public void Options_scope_the_singleton_to_the_nodes_cluster_role() { var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( - new FakeClusterRoleInfo(clusterRole: "cluster-SITE-A", clusterId: "SITE-A")); + new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } }); options.Role.ShouldBe("cluster-SITE-A"); } @@ -51,7 +51,7 @@ public sealed class RedundancyStateSingletonRehomeTests public void Missing_cluster_role_falls_back_to_the_driver_role() { var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( - new FakeClusterRoleInfo(clusterRole: null, clusterId: null)); + new AkkaClusterOptions { Roles = new[] { "admin", "driver" } }); options.Role.ShouldBe(RoleParser.Driver); } @@ -83,7 +83,7 @@ public sealed class RedundancyStateSingletonRehomeTests var registry = await BootAndGetRegistryAsync( roles: new[] { "driver", "cluster-SITE-A" }, configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( - new FakeClusterRoleInfo(clusterRole: "cluster-SITE-A", clusterId: "SITE-A"))); + new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } })); registry.TryGet(out _).ShouldBeTrue( "every driver node hosts its own mesh's redundancy singleton (Phase 6)"); @@ -102,7 +102,7 @@ public sealed class RedundancyStateSingletonRehomeTests var registry = await BootAndGetRegistryAsync( roles: new[] { "driver" }, configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( - new FakeClusterRoleInfo(clusterRole: null, clusterId: null))); + new AkkaClusterOptions { Roles = new[] { "driver" } })); registry.TryGet(out _).ShouldBeTrue( "a driver node with no cluster role falls back to the driver role and still boots + registers");