fix(mesh): break Phase 6 boot-crash — cluster-redundancy singleton scope must not resolve IClusterRoleInfo eagerly

Phase 6 wired the re-homed redundancy singleton in Program.cs as
`WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>())`
inside the AddAkka configurator lambda. That lambda runs WHILE the ActorSystem is
being built, but ClusterRoleInfo depends on the ActorSystem (it is a live
Cluster.State view) — so resolving it there recurses into ActorSystem construction
and every node dies at boot with StackOverflowException. It compiles cleanly (a
runtime DI cycle) and RedundancyStateSingletonRehomeTests passed a hand-built
FakeClusterRoleInfo straight into the extension, mocking around the composition-root
line that overflows. The docker-dev live gate caught it on first boot.

Derive the singleton's cluster-role scope from AkkaClusterOptions (pure config, no
ActorSystem) instead: BuildClusterRedundancySingletonOptions / the extension now take
AkkaClusterOptions and compute Role = Roles.FirstOrDefault(IsClusterRole) ?? driver —
identical to ClusterRoleInfo.ClusterRole, which derives from the same config. Program.cs
passes IOptions<AkkaClusterOptions>.Value, which has no ActorSystem dependency.

Live-verified: rebuilt image boots with zero stack-overflow lines, cluster singletons
form per mesh. 5/5 rehome tests pass; Host builds clean.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 06:54:14 -04:00
parent 2839deb5be
commit 3a4ed8ddb5
3 changed files with 27 additions and 13 deletions
@@ -189,9 +189,9 @@ public static class ServiceCollectionExtensions
/// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub. /// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub.
/// </remarks> /// </remarks>
public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton( public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton(
this AkkaConfigurationBuilder builder, IClusterRoleInfo roleInfo) this AkkaConfigurationBuilder builder, AkkaClusterOptions clusterOptions)
{ {
var singletonOptions = BuildClusterRedundancySingletonOptions(roleInfo); var singletonOptions = BuildClusterRedundancySingletonOptions(clusterOptions);
builder.WithSingleton<RedundancyStateActorKey>( builder.WithSingleton<RedundancyStateActorKey>(
RedundancyStateSingletonName, RedundancyStateSingletonName,
@@ -216,13 +216,23 @@ public static class ServiceCollectionExtensions
/// (b) on a genuinely split 2-node mesh the <c>driver</c> role is already pair-local — the mesh IS /// (b) on a genuinely split 2-node mesh the <c>driver</c> role is already pair-local — the mesh IS
/// the pair. So the fallback is correct in both worlds and costs no availability. /// the pair. So the fallback is correct in both worlds and costs no availability.
/// </remarks> /// </remarks>
/// <param name="roleInfo">The local node's cluster-role view.</param> /// <param name="clusterOptions">The node's own cluster configuration (roles), read without the
/// ActorSystem so this is safe to call inside the Akka configurator lambda.</param>
/// <returns>Singleton options scoped to the node's cluster role, or the <c>driver</c> role.</returns> /// <returns>Singleton options scoped to the node's cluster role, or the <c>driver</c> role.</returns>
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 };
} }
} }
+7 -3
View File
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Hosting; using Akka.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Serilog; using Serilog;
using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.LocalDb.Replication;
using Serilog.Events; 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 // 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 // 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, // 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 // since the admin branch above no longer registers one. Pass AkkaClusterOptions (pure config),
// AddOtOpcUaCluster and resolved lazily here. // NOT IClusterRoleInfo: this lambda runs WHILE the ActorSystem is being built, and
ab.WithOtOpcUaClusterRedundancySingleton(sp.GetRequiredService<IClusterRoleInfo>()); // 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<IOptions<AkkaClusterOptions>>().Value);
} }
}); });
@@ -36,7 +36,7 @@ public sealed class RedundancyStateSingletonRehomeTests
public void Options_scope_the_singleton_to_the_nodes_cluster_role() public void Options_scope_the_singleton_to_the_nodes_cluster_role()
{ {
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( 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"); 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() public void Missing_cluster_role_falls_back_to_the_driver_role()
{ {
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions( var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
new FakeClusterRoleInfo(clusterRole: null, clusterId: null)); new AkkaClusterOptions { Roles = new[] { "admin", "driver" } });
options.Role.ShouldBe(RoleParser.Driver); options.Role.ShouldBe(RoleParser.Driver);
} }
@@ -83,7 +83,7 @@ public sealed class RedundancyStateSingletonRehomeTests
var registry = await BootAndGetRegistryAsync( var registry = await BootAndGetRegistryAsync(
roles: new[] { "driver", "cluster-SITE-A" }, roles: new[] { "driver", "cluster-SITE-A" },
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
new FakeClusterRoleInfo(clusterRole: "cluster-SITE-A", clusterId: "SITE-A"))); new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } }));
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue( registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
"every driver node hosts its own mesh's redundancy singleton (Phase 6)"); "every driver node hosts its own mesh's redundancy singleton (Phase 6)");
@@ -102,7 +102,7 @@ public sealed class RedundancyStateSingletonRehomeTests
var registry = await BootAndGetRegistryAsync( var registry = await BootAndGetRegistryAsync(
roles: new[] { "driver" }, roles: new[] { "driver" },
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton( configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
new FakeClusterRoleInfo(clusterRole: null, clusterId: null))); new AkkaClusterOptions { Roles = new[] { "driver" } }));
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue( registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
"a driver node with no cluster role falls back to the driver role and still boots + registers"); "a driver node with no cluster role falls back to the driver role and still boots + registers");