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:
@@ -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
|
||||
|
||||
+29
-5
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user