docs(mesh): fix stale redundancy-singleton comments + prove the driver fallback end-to-end
Review follow-ups on the redundancy re-home (Phase 6, Task 2):
- Program.cs: the hasDriver comment claimed IClusterRoleInfo "throws at host
start if this driver node carries no cluster role" — false since the fallback
landed. Rewrite it to describe the cluster-{ClusterId} scope with the driver-role
fallback for legacy single-mesh / not-yet-migrated nodes.
- ClusterRoleInfo.cs: the SubscriberActor comment described RedundancyStateActor as
the "admin-role singleton" — stale. Note it is now a cluster-{ClusterId}-scoped
singleton spawned on every driver node (LeaderChanged stays a no-op here).
- Add a host/registry-level test for the driver-role FALLBACK path (roles ["driver"],
no cluster role) closing the coverage asymmetry — it was proven only in the pure
helper. Asserts the node boots and registers RedundancyStateActorKey, i.e. the boot
the earlier throw-based version would have aborted.
The boot helper now supplies an in-memory IDbContextFactory and a fake IClusterRoleInfo:
Akka.Hosting invokes singleton props factories eagerly at StartAsync, and the admin
ClusterNodeAddressReconciler factory reads both (the latter for its per-cluster reconcile
scope, added by a sibling Phase 6 task) — without them the admin boot NREs.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -211,7 +211,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
|
||||
Receive<ClusterEvent.RoleLeaderChanged>(e => owner.HandleRoleLeaderChanged(e));
|
||||
// LeaderChanged is intentionally a no-op here: ServiceLevel lives in RedundancyStateActor
|
||||
// (admin-role singleton) and has no consumer on this ClusterRoleInfo path by design.
|
||||
// (per-cluster mesh Phase 6: a cluster-{ClusterId}-scoped singleton spawned on every
|
||||
// driver node, no longer the admin-role singleton it once was) and has no consumer on this
|
||||
// ClusterRoleInfo path by design.
|
||||
Receive<ClusterEvent.LeaderChanged>(_ => { });
|
||||
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
|
||||
}
|
||||
|
||||
@@ -375,12 +375,13 @@ builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||
{
|
||||
ab.WithOtOpcUaRuntimeActors();
|
||||
// Per-cluster mesh Phase 6: re-home the redundancy-state singleton off the admin node onto
|
||||
// EVERY driver node, scoped to the node's own cluster-{ClusterId} role, so each mesh elects
|
||||
// its own pair-local Primary (a driver-only site pair has no admin node to do it). 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 (resolved lazily here); it throws at
|
||||
// host start if this driver node carries no cluster-{ClusterId} role.
|
||||
// EVERY driver node, so each mesh elects its own pair-local Primary (a driver-only site pair
|
||||
// has no admin node to do it). It scopes to this node's own cluster-{ClusterId} role, falling
|
||||
// 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>());
|
||||
}
|
||||
});
|
||||
|
||||
+30
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -9,6 +10,7 @@ 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;
|
||||
|
||||
@@ -87,6 +89,25 @@ public sealed class RedundancyStateSingletonRehomeTests
|
||||
"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 FakeClusterRoleInfo(clusterRole: null, clusterId: null)));
|
||||
|
||||
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
|
||||
@@ -112,6 +133,15 @@ public sealed class RedundancyStateSingletonRehomeTests
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user