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
247 lines
14 KiB
C#
247 lines
14 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster.Hosting;
|
|
using Akka.Cluster.Tools.Client;
|
|
using Akka.Hosting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
public const string AdminRole = "admin";
|
|
|
|
public const string ConfigPublishSingletonName = "config-publish";
|
|
public const string AdminOperationsSingletonName = "admin-operations";
|
|
public const string AuditWriterSingletonName = "audit-writer";
|
|
public const string FleetStatusSingletonName = "fleet-status";
|
|
public const string RedundancyStateSingletonName = "redundancy-state";
|
|
public const string ClusterNodeAddressReconcilerSingletonName = "cluster-node-address-reconciler";
|
|
|
|
/// <summary>
|
|
/// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — five with
|
|
/// registry proxies, plus the address reconciler, which is proxy-less because nothing addresses it.
|
|
/// Must be called against the same builder used by <c>AkkaHostedService</c> so the singletons
|
|
/// share the host's ActorSystem.
|
|
/// </summary>
|
|
/// <param name="builder">The Akka configuration builder.</param>
|
|
/// <returns>The builder for fluent chaining.</returns>
|
|
/// <remarks>
|
|
/// Wire from the fused Host's Program.cs:
|
|
/// <code>
|
|
/// builder.Services.AddAkka("otopcua", (ab, sp) =>
|
|
/// {
|
|
/// ab.WithRemoting(/* ... */).WithClustering(/* ... */);
|
|
/// ab.WithOtOpcUaControlPlaneSingletons();
|
|
/// });
|
|
/// </code>
|
|
/// </remarks>
|
|
public static AkkaConfigurationBuilder WithOtOpcUaControlPlaneSingletons(this AkkaConfigurationBuilder builder)
|
|
{
|
|
var singletonOptions = new ClusterSingletonOptions { Role = AdminRole };
|
|
var proxyOptions = new ClusterSingletonOptions { Role = AdminRole };
|
|
|
|
// Per-cluster mesh Phase 2: the central end of the central↔node command boundary.
|
|
//
|
|
// A plain WithActors, NOT a singleton — and that is the load-bearing part. A driver node's
|
|
// ClusterClient rotates across contact points and must find a live comm actor at whichever
|
|
// central node answers. As a singleton it would exist on one central node only, and the
|
|
// rotation would fail silently against the other: acks would land sometimes, depending on
|
|
// which contact the client happened to settle on.
|
|
builder.WithActors((system, registry, resolver) =>
|
|
{
|
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
var options = resolver.GetService<IOptions<MeshTransportOptions>>().Value;
|
|
|
|
var actor = system.ActorOf(
|
|
CentralCommunicationActor.Props(
|
|
dbFactory,
|
|
options,
|
|
new DefaultMeshClusterClientFactory(),
|
|
// Resolved lazily, per message, so this actor never depends on the order in
|
|
// which Akka.Hosting materialises the singleton proxy relative to this block.
|
|
() => registry.TryGet<ConfigPublishCoordinatorKey>(out var coordinator)
|
|
? coordinator
|
|
: null),
|
|
MeshPaths.CentralCommunicationName);
|
|
|
|
// Registered unconditionally, in both transport modes. Under Dps nothing dials it, and
|
|
// an idle registration costs nothing — whereas registering only under ClusterClient
|
|
// would mean flipping the flag on a running fleet requires a central restart before any
|
|
// node can ack, turning a config change back into a deployment.
|
|
ClusterClientReceptionist.Get(system).RegisterService(actor);
|
|
registry.Register<CentralCommunicationActorKey>(actor);
|
|
});
|
|
|
|
builder.WithSingleton<ConfigPublishCoordinatorKey>(
|
|
ConfigPublishSingletonName,
|
|
(system, registry, resolver) =>
|
|
{
|
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
// Registered above, so it is already in the registry — the same
|
|
// ordering-by-registration the AdminOperations singleton relies on to resolve the
|
|
// coordinator. Deliberately NOT an ActorSelection.ResolveOne here: that blocks
|
|
// inside a props factory and races the spawn it is waiting for.
|
|
var meshRouter = registry.Get<CentralCommunicationActorKey>();
|
|
return ConfigPublishCoordinator.Props(dbFactory, applyDeadline: null, meshRouter: meshRouter);
|
|
},
|
|
singletonOptions);
|
|
|
|
builder.WithSingleton<AdminOperationsActorKey>(
|
|
AdminOperationsSingletonName,
|
|
(system, registry, resolver) =>
|
|
{
|
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
|
|
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
|
|
// R2-11 (05/CONV-2): deploy-gate TagConfig strictness mode from appsettings
|
|
// (Deployment:TagConfigValidationMode = Warn default | Error opt-in).
|
|
var mode = Enum.TryParse<TagConfigValidationMode>(
|
|
resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"],
|
|
ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn;
|
|
var meshRouter = registry.Get<CentralCommunicationActorKey>();
|
|
return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode, meshRouter);
|
|
},
|
|
singletonOptions);
|
|
|
|
builder.WithSingleton<AuditWriterActorKey>(
|
|
AuditWriterSingletonName,
|
|
(system, registry, resolver) =>
|
|
{
|
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
return AuditWriterActor.Props(dbFactory);
|
|
},
|
|
singletonOptions);
|
|
|
|
builder.WithSingleton<FleetStatusBroadcasterKey>(
|
|
FleetStatusSingletonName,
|
|
(system, registry, resolver) => FleetStatusBroadcaster.Props(),
|
|
singletonOptions);
|
|
|
|
// Per-cluster mesh Phase 6: the redundancy-state singleton is NO LONGER registered here.
|
|
// It was an admin-scoped, fleet-wide singleton — one Primary elected across the whole mesh.
|
|
// After the mesh split a driver-only site pair has no admin node to host it, so it moved to
|
|
// WithOtOpcUaClusterRedundancySingleton, a cluster-{ClusterId}-scoped singleton spawned on
|
|
// EVERY driver node (the fused central included). One per mesh, electing its own pair-local
|
|
// Primary and publishing on its own mesh's DistributedPubSub.
|
|
|
|
// Per-cluster mesh Phase 1: ClusterNode.AkkaPort duplicates the node's own Cluster:Port and
|
|
// nothing makes them agree. Runs here rather than on each driver node because Phase 4 removes
|
|
// the driver nodes' ConfigDb connection entirely.
|
|
//
|
|
// createProxyToo: false — unlike the five singletons above, nothing ever resolves
|
|
// ClusterNodeAddressReconcilerKey from the registry. This actor only reads cluster state and
|
|
// logs; no one sends it messages. A proxy would be a second actor per node whose only job is
|
|
// to hunt for a singleton nobody addresses — visible as the "ClusterSingletonProxy failed to
|
|
// find an associated singleton after 30 seconds" startup warning, and as extra cluster
|
|
// chatter in every two-node test harness the integration suite builds.
|
|
builder.WithSingleton<ClusterNodeAddressReconcilerKey>(
|
|
ClusterNodeAddressReconcilerSingletonName,
|
|
(system, registry, resolver) =>
|
|
{
|
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
// Per-cluster mesh Phase 6: scope BOTH halves of the reconcile to the admin node's OWN
|
|
// cluster — rows by ClusterId, observed membership by cluster role. After the mesh split
|
|
// central sees only its own pair via gossip, so an unscoped sweep would false-report
|
|
// every other cluster's rows (EnabledRowNotInCluster). Scoping only the rows would swap
|
|
// that for a WORSE Error-level RunningNodeHasNoRow in the mixed-mesh window (rows already
|
|
// filtered, but foreign members still visible via gossip). A legacy node with no cluster
|
|
// role has null ClusterId/ClusterRole ⇒ full-fleet reconcile, unchanged.
|
|
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
|
return ClusterNodeAddressReconcilerActor.Props(
|
|
dbFactory, roleInfo.ClusterId, roleInfo.ClusterRole);
|
|
},
|
|
singletonOptions,
|
|
createProxyToo: false);
|
|
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers <see cref="RedundancyStateActor"/> as a cluster singleton scoped to the local node's
|
|
/// OWN <c>cluster-{ClusterId}</c> role (or the fixed <c>driver</c> role when the node carries no
|
|
/// cluster role), and spawns it on EVERY driver node (per-cluster mesh, Phase 6). Wire from the
|
|
/// driver branch of the fused Host's Program.cs — the fused central node is <c>hasDriver</c> too,
|
|
/// so it hosts exactly one redundancy singleton scoped to its own cluster role (e.g.
|
|
/// <c>cluster-MAIN</c>), while the admin singleton set it also registers no longer carries
|
|
/// redundancy.
|
|
/// </summary>
|
|
/// <param name="builder">The Akka configuration builder.</param>
|
|
/// <param name="roleInfo">The local node's cluster-role view (Task 1's <c>ClusterRoleInfo</c>).</param>
|
|
/// <returns>The builder for fluent chaining.</returns>
|
|
/// <remarks>
|
|
/// After the mesh split, central's mesh sees only its own pair — a driver-only site pair therefore
|
|
/// has NO admin node to elect its Primary. Scoping this singleton to the node's cluster role and
|
|
/// spawning it on every driver node gives each mesh exactly one, electing its own pair-local
|
|
/// Primary and publishing <c>redundancy-state</c> on its own mesh's DistributedPubSub.
|
|
/// </remarks>
|
|
public static AkkaConfigurationBuilder WithOtOpcUaClusterRedundancySingleton(
|
|
this AkkaConfigurationBuilder builder, AkkaClusterOptions clusterOptions)
|
|
{
|
|
var singletonOptions = BuildClusterRedundancySingletonOptions(clusterOptions);
|
|
|
|
builder.WithSingleton<RedundancyStateActorKey>(
|
|
RedundancyStateSingletonName,
|
|
(system, registry, resolver) => RedundancyStateActor.Props(),
|
|
singletonOptions);
|
|
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the <see cref="ClusterSingletonOptions"/> that scope the redundancy-state singleton to
|
|
/// the local node's <c>cluster-{ClusterId}</c> role, falling back to the fixed <c>driver</c> role
|
|
/// when the node carries no cluster role. Extracted (like
|
|
/// <c>SplitBrainResolverActivationTests</c>'s <c>BuildDowningHocon</c>) so the exact role scope is
|
|
/// unit-assertable without booting a cluster.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Cluster-scope is the robust choice — it survives an accidental two-mesh merge by keeping one
|
|
/// singleton per cluster. The <c>driver</c>-role fallback deliberately does NOT throw when a node
|
|
/// has no cluster role, because (a) that is the pre-Phase-6 fleet-wide behavior on a legacy single
|
|
/// mesh, keeping legacy/harness nodes and any not-yet-migrated deployment booting unchanged, and
|
|
/// (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="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(AkkaClusterOptions clusterOptions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(clusterOptions);
|
|
|
|
// 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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>Marker key types used by <c>Akka.Hosting</c> to resolve singletons from the registry.</summary>
|
|
public sealed class ConfigPublishCoordinatorKey { }
|
|
public sealed class AdminOperationsActorKey { }
|
|
public sealed class AuditWriterActorKey { }
|
|
public sealed class FleetStatusBroadcasterKey { }
|
|
public sealed class RedundancyStateActorKey { }
|
|
public sealed class ClusterNodeAddressReconcilerKey { }
|
|
public sealed class CentralCommunicationActorKey { }
|