3a4ed8ddb5
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
183 lines
8.7 KiB
C#
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 { } }
|
|
}
|
|
}
|