From a4d3ab4005e68957cecf883718f9b5d53d574ea0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 01:58:48 -0400 Subject: [PATCH] docs(mesh): fix stale redundancy-singleton comments + prove the driver fallback end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ClusterRoleInfo.cs | 4 ++- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 13 ++++---- .../RedundancyStateSingletonRehomeTests.cs | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs index 5ac97d95..d1f660b6 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs @@ -211,7 +211,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable Receive(e => owner.HandleMemberEvent(e)); Receive(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(_ => { }); Receive(_ => { /* seeded from initial snapshot */ }); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index b6bf8b31..0844e115 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -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()); } }); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs index 9f447776..f92133d4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateSingletonRehomeTests.cs @@ -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)"); } + /// + /// The driver-role FALLBACK end-to-end: a node carrying only driver (no cluster role — a + /// legacy single-mesh node or the TwoNodeClusterHarness) 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. + /// + [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(out _).ShouldBeTrue( + "a driver node with no cluster role falls back to the driver role and still boots + registers"); + } + /// /// 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>(Options.Create(options)); // The admin WithActors block reads IOptions.Value at spawn time. services.AddSingleton>(Options.Create(new MeshTransportOptions())); + // Akka.Hosting invokes every singleton's props factory eagerly at StartAsync; the admin + // singletons (coordinator/audit/reconciler) read IDbContextFactory, + // so supply an in-memory one or those factories NRE. The driver-only boots don't touch it. + services.AddDbContextFactory( + 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(new FakeClusterRoleInfo(clusterRole: null, clusterId: null)); services.AddAkka("otopcua-rehome-test", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp);