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");