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:
@@ -189,9 +189,9 @@ public static class ServiceCollectionExtensions
|
||||
/// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub.
|
||||
/// </remarks>
|
||||
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>(
|
||||
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
|
||||
/// the pair. So the fallback is correct in both worlds and costs no availability.
|
||||
/// </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>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IClusterRoleInfo>());
|
||||
// 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<IOptions<AkkaClusterOptions>>().Value);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user