fix(mesh): scope ClusterNode address reconcile to the admin node's own cluster

Phase 6 splits the fleet into one mesh per Cluster, so an admin (central)
node's Cluster.State.Members shows only its own pair — it no longer sees
site members via gossip. The ClusterNodeAddressReconciler singleton compared
live membership against EVERY ClusterNode row fleet-wide, so post-split every
foreign-cluster row would log EnabledRowNotInCluster forever.

Scope the actor's two DB queries to rows whose ClusterId matches the admin
node's own (IClusterRoleInfo.ClusterId, sourced at registration). A legacy
admin node with no cluster role (null ClusterId) still reconciles the whole
fleet — it genuinely sees every member via gossip. The pure Reconcile
function and AddressMismatchKind semantics are unchanged; scoping lives
entirely in the actor's queries.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 01:39:06 -04:00
parent a2c5d57fa0
commit 2535df019b
6 changed files with 149 additions and 40 deletions
@@ -58,10 +58,16 @@ public sealed record ClusterNodeAddress(string NodeId, string Host, int AkkaPort
/// to be deleted again.
/// </para>
/// <para>
/// <b>Phase 2 must revisit this.</b> Once the fleet splits into one mesh per cluster, an
/// admin node no longer sees site members at all, and every site row would report
/// <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> forever. The check then has to
/// move to whatever transport replaces gossip — or be scoped to the central mesh.
/// <b>Scoped to the admin node's own cluster (Phase 6).</b> Once the fleet splits into one
/// mesh per cluster, an admin node no longer sees site members at all, and a fleet-wide sweep
/// would report every other cluster's row as
/// <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> forever. The actor therefore loads
/// only rows whose <c>ClusterId</c> matches the admin node's own
/// (<see cref="ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IClusterRoleInfo.ClusterId"/>) before
/// calling <see cref="Reconcile"/>, so it never false-reports a cluster it cannot observe. A
/// legacy admin node carrying no cluster role (null <c>ClusterId</c>) still sees every member
/// via gossip and reconciles the full fleet unchanged. The pure function below is untouched by
/// this — scoping happens entirely in the actor's DB queries.
/// </para>
/// </remarks>
public static class ClusterNodeAddressReconciler
@@ -30,6 +30,7 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
public static readonly TimeSpan DefaultSweepInterval = TimeSpan.FromMinutes(5);
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly string? _ownClusterId;
private readonly Akka.Cluster.Cluster _cluster;
private readonly TimeSpan _sweepInterval;
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -40,19 +41,35 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
/// <summary>Creates Props for the reconciler.</summary>
/// <param name="dbFactory">Factory for the config database context.</param>
/// <param name="ownClusterId">
/// The admin node's own <c>ClusterId</c> (e.g. <c>MAIN</c>), from
/// <see cref="IClusterRoleInfo.ClusterId"/>. Non-null/empty scopes the reconcile to rows in that
/// cluster — the only members central's own mesh can see after the Phase 6 split. Null/empty
/// (a legacy admin node with no cluster role) reconciles the whole fleet, unchanged.
/// </param>
/// <param name="sweepInterval">Periodic sweep interval; defaults to <see cref="DefaultSweepInterval"/>.</param>
/// <returns>Props for actor creation.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, TimeSpan? sweepInterval = null) =>
Akka.Actor.Props.Create(() => new ClusterNodeAddressReconcilerActor(dbFactory, sweepInterval));
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
string? ownClusterId = null,
TimeSpan? sweepInterval = null) =>
Akka.Actor.Props.Create(() =>
new ClusterNodeAddressReconcilerActor(dbFactory, ownClusterId, sweepInterval));
/// <summary>Initializes a new instance of the <see cref="ClusterNodeAddressReconcilerActor"/> class.</summary>
/// <param name="dbFactory">Factory for the config database context.</param>
/// <param name="ownClusterId">
/// The admin node's own <c>ClusterId</c>; scopes the reconcile to that cluster's rows when
/// non-null/empty, else reconciles the whole fleet (legacy behaviour).
/// </param>
/// <param name="sweepInterval">Periodic sweep interval; defaults to <see cref="DefaultSweepInterval"/>.</param>
public ClusterNodeAddressReconcilerActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, TimeSpan? sweepInterval = null)
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
string? ownClusterId = null,
TimeSpan? sweepInterval = null)
{
_dbFactory = dbFactory;
_ownClusterId = string.IsNullOrEmpty(ownClusterId) ? null : ownClusterId;
_cluster = Akka.Cluster.Cluster.Get(Context.System);
_sweepInterval = sweepInterval ?? DefaultSweepInterval;
@@ -87,13 +104,20 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
try
{
using var db = _dbFactory.CreateDbContext();
rows = db.ClusterNodes.AsNoTracking()
// Per-cluster mesh Phase 6: scope to the admin node's OWN cluster's rows. After the split,
// central's mesh sees only its own pair, so a row in another cluster it cannot observe would
// false-report EnabledRowNotInCluster forever. A legacy admin node with no cluster role
// (_ownClusterId == null) still sees every member via gossip, so it reconciles the full fleet.
var scoped = db.ClusterNodes.AsNoTracking();
if (_ownClusterId is not null)
scoped = scoped.Where(n => n.ClusterId == _ownClusterId);
rows = scoped
.Select(n => new ClusterNodeAddress(n.NodeId, n.Host, n.AkkaPort))
.ToList();
// Same predicate the coordinator's expected-ack set uses — a node the deploy path will
// not wait for must not be reported as missing either, or the maintenance hatch trades a
// failed deployment for a permanent warning.
enabled = db.ClusterNodes.AsNoTracking()
enabled = scoped
.Where(n => n.Enabled && !n.MaintenanceMode).Select(n => n.NodeId).ToList();
}
catch (Exception ex)
@@ -153,7 +153,12 @@ public static class ServiceCollectionExtensions
(system, registry, resolver) =>
{
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
return ClusterNodeAddressReconcilerActor.Props(dbFactory);
// Per-cluster mesh Phase 6: scope the reconcile to the admin node's OWN cluster's rows.
// After the mesh split central sees only its own pair via gossip, so a fleet-wide sweep
// would false-report every other cluster's rows as EnabledRowNotInCluster forever. A
// legacy node with no cluster role has null ClusterId ⇒ full-fleet reconcile, unchanged.
var roleInfo = resolver.GetService<IClusterRoleInfo>();
return ClusterNodeAddressReconcilerActor.Props(dbFactory, roleInfo.ClusterId);
},
singletonOptions,
createProxyToo: false);