Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs
T
Joseph Doherty 3a4ed8ddb5 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
2026-07-24 06:54:14 -04:00

183 lines
8.7 KiB
C#

using Akka.Actor;
using Akka.Cluster.Hosting;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
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.ControlPlane.Tests;
/// <summary>
/// Guards Phase 6's re-homing of <c>RedundancyStateActor</c> off the fleet-wide admin singleton onto
/// a per-node <c>cluster-{ClusterId}</c> singleton (see
/// <see cref="ServiceCollectionExtensions.WithOtOpcUaClusterRedundancySingleton"/>). After the mesh
/// split a driver-only site pair has NO admin node to elect its Primary, so the singleton must be
/// scoped to the node's own cluster role and spawned on every driver node — one per mesh.
///
/// <para>The role scope + the missing-role guard are asserted against the extracted
/// <see cref="ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions"/> helper directly —
/// the same pattern <c>SplitBrainResolverActivationTests</c> uses for <c>BuildDowningHocon</c> /
/// <c>BuildClusterOptions</c>: it pins the exact <see cref="ClusterSingletonOptions.Role"/> without
/// booting a cluster, which no builder-introspection API can otherwise reach. The admin-removal claim
/// is behavioral (a booted admin node's <see cref="ActorRegistry"/> must no longer carry the
/// redundancy key) so it needs a real host.</para>
/// </summary>
public sealed class RedundancyStateSingletonRehomeTests
{
/// <summary>The singleton options scope to the node's OWN cluster role, not the fixed admin role.</summary>
[Fact]
public void Options_scope_the_singleton_to_the_nodes_cluster_role()
{
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } });
options.Role.ShouldBe("cluster-SITE-A");
}
/// <summary>
/// A node with no <c>cluster-{ClusterId}</c> role (a legacy single-mesh node, or the
/// <c>TwoNodeClusterHarness</c> with roles <c>admin,driver</c>) falls back to the fixed
/// <c>driver</c> role rather than throwing — so legacy/not-yet-migrated deployments keep booting.
/// On a split 2-node mesh the <c>driver</c> role is already pair-local (the mesh IS the pair).
/// </summary>
[Fact]
public void Missing_cluster_role_falls_back_to_the_driver_role()
{
var options = ServiceCollectionExtensions.BuildClusterRedundancySingletonOptions(
new AkkaClusterOptions { Roles = new[] { "admin", "driver" } });
options.Role.ShouldBe(RoleParser.Driver);
}
/// <summary>
/// The admin singleton set no longer registers <c>redundancy-state</c>: a booted admin node's
/// registry carries the OTHER admin singletons (proxy) but NOT the redundancy key.
/// </summary>
[Fact]
public async Task Admin_singletons_no_longer_register_the_redundancy_singleton()
{
var registry = await BootAndGetRegistryAsync(
roles: new[] { "admin" },
configure: ab => ab.WithOtOpcUaControlPlaneSingletons());
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeFalse(
"the redundancy singleton was re-homed off the admin node onto every driver node (Phase 6)");
registry.TryGet<FleetStatusBroadcasterKey>(out _).ShouldBeTrue(
"the remaining admin singletons must still register");
}
/// <summary>
/// The re-home target: a driver node carrying a <c>cluster-{ClusterId}</c> role registers the
/// redundancy singleton (proxy) via the new extension.
/// </summary>
[Fact]
public async Task Cluster_redundancy_extension_registers_the_singleton_on_a_driver_node()
{
var registry = await BootAndGetRegistryAsync(
roles: new[] { "driver", "cluster-SITE-A" },
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
new AkkaClusterOptions { Roles = new[] { "driver", "cluster-SITE-A" } }));
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
"every driver node hosts its own mesh's redundancy singleton (Phase 6)");
}
/// <summary>
/// The driver-role FALLBACK end-to-end: a node carrying only <c>driver</c> (no cluster role — a
/// legacy single-mesh node or the <c>TwoNodeClusterHarness</c>) still boots and registers the
/// redundancy singleton via the new extension. Proves the fallback works at the host/registry
/// level, not just in the pure helper — the boot that the earlier throw-based version would have
/// aborted.
/// </summary>
[Fact]
public async Task Driver_role_fallback_registers_the_singleton_end_to_end()
{
var registry = await BootAndGetRegistryAsync(
roles: new[] { "driver" },
configure: ab => ab.WithOtOpcUaClusterRedundancySingleton(
new AkkaClusterOptions { Roles = new[] { "driver" } }));
registry.TryGet<RedundancyStateActorKey>(out _).ShouldBeTrue(
"a driver node with no cluster role falls back to the driver role and still boots + registers");
}
/// <summary>
/// Boots a real host through the production cluster bootstrap (Port=0, no seeds, so it never forms
/// a cluster and no singleton props factory runs — only the proxies register), applies the
/// supplied singleton registration, and returns the <see cref="ActorRegistry"/> after teardown.
/// Start/stop live here rather than the test bodies to keep the CancellationToken-less calls out
/// of the way (mirrors <c>ClusterRoleInfoTests.ResolveAsync</c>).
/// </summary>
private static async Task<ActorRegistry> BootAndGetRegistryAsync(
string[] roles, Action<AkkaConfigurationBuilder> configure)
{
var options = new AkkaClusterOptions
{
Port = 0,
Hostname = "127.0.0.1",
PublicHostname = "127.0.0.1",
SeedNodes = Array.Empty<string>(),
Roles = roles,
};
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
// The admin WithActors block reads IOptions<MeshTransportOptions>.Value at spawn time.
services.AddSingleton<IOptions<MeshTransportOptions>>(Options.Create(new MeshTransportOptions()));
// Akka.Hosting invokes every singleton's props factory eagerly at StartAsync; the admin
// singletons (coordinator/audit/reconciler) read IDbContextFactory<OtOpcUaConfigDbContext>,
// so supply an in-memory one or those factories NRE. The driver-only boots don't touch it.
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
o => o.UseInMemoryDatabase($"rehome-test-{Guid.NewGuid():N}"));
// The admin ClusterNodeAddressReconciler props factory resolves IClusterRoleInfo (for its
// per-cluster reconcile scope). A null cluster role ⇒ full-fleet reconcile, which is all
// this test needs — it never asserts on the reconciler, only on the singleton registry.
services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo(clusterRole: null, clusterId: null));
services.AddAkka("otopcua-rehome-test", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
configure(ab);
});
});
using var host = builder.Build();
await host.StartAsync();
try
{
return host.Services.GetRequiredService<ActorRegistry>();
}
finally
{
await host.StopAsync();
}
}
/// <summary>Minimal <see cref="IClusterRoleInfo"/> stub exposing only the cluster-role identity.</summary>
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
{
public FakeClusterRoleInfo(string? clusterRole, string? clusterId)
{
ClusterRole = clusterRole;
ClusterId = clusterId;
}
public string? ClusterRole { get; }
public string? ClusterId { get; }
public NodeId LocalNode => NodeId.Parse("127.0.0.1:0");
public IReadOnlySet<string> LocalRoles => new HashSet<string>();
public bool HasRole(string role) => false;
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
public NodeId? RoleLeader(string role) => null;
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged { add { } remove { } }
}
}