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:
+67
-11
@@ -70,24 +70,80 @@ public sealed class ClusterNodeAddressReconcilerActorTests : ControlPlaneActorTe
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-cluster mesh Phase 6: an admin node scoped to its OWN cluster ignores rows in a cluster
|
||||
/// its mesh can no longer see. Scoped to <c>MAIN</c>, a <c>SITE-A</c> enabled row is filtered
|
||||
/// out before <see cref="ClusterNodeAddressReconciler.Reconcile"/> ever sees it, so it produces
|
||||
/// no <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> warning. This is the false
|
||||
/// positive the split would otherwise print forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Row_in_a_foreign_cluster_is_ignored_when_scoped()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A");
|
||||
|
||||
EventFilter.Warning(contains: "site-a-1:4053").Expect(0, TimeSpan.FromSeconds(2), () =>
|
||||
{
|
||||
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(
|
||||
dbFactory, ownClusterId: "MAIN", sweepInterval: TimeSpan.FromMilliseconds(400)));
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1.5));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoping does not silence the admin node's own cluster. Scoped to <c>MAIN</c>, an enabled
|
||||
/// <c>MAIN</c> row with no matching driver member is still warned about — proving the filter
|
||||
/// keeps in-cluster rows (and with them, within-cluster drift detection).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Enabled_row_in_own_cluster_is_still_logged_when_scoped()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
SeedNode(dbFactory, "main-1:4053", enabled: true, clusterId: "MAIN");
|
||||
|
||||
EventFilter.Warning(contains: "main-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
||||
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: "MAIN")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy backward-compat: an admin node with no cluster role (null <c>ownClusterId</c>) still
|
||||
/// reconciles the WHOLE fleet — such a node genuinely sees every member via gossip. A
|
||||
/// <c>SITE-A</c> enabled row with no member is warned about, exactly as before the Phase 6 scope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Foreign_cluster_row_is_still_logged_when_unscoped()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
SeedNode(dbFactory, "site-a-1:4053", enabled: true, clusterId: "SITE-A");
|
||||
|
||||
EventFilter.Warning(contains: "site-a-1:4053").ExpectOne(TimeSpan.FromSeconds(10), () =>
|
||||
Sys.ActorOf(ClusterNodeAddressReconcilerActor.Props(dbFactory, ownClusterId: null)));
|
||||
}
|
||||
|
||||
private static void SeedNode(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string nodeId, bool enabled)
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string nodeId, bool enabled,
|
||||
string clusterId = "SITE-A")
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
if (!db.ServerClusters.Any(c => c.ClusterId == clusterId))
|
||||
{
|
||||
ClusterId = "SITE-A",
|
||||
Name = "Site A",
|
||||
Enterprise = "zb",
|
||||
Site = "site-a",
|
||||
NodeCount = 2,
|
||||
RedundancyMode = RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Name = clusterId,
|
||||
Enterprise = "zb",
|
||||
Site = clusterId.ToLowerInvariant(),
|
||||
NodeCount = 2,
|
||||
RedundancyMode = RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "SITE-A",
|
||||
ClusterId = clusterId,
|
||||
Host = nodeId.Split(':')[0],
|
||||
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
|
||||
Enabled = enabled,
|
||||
|
||||
Reference in New Issue
Block a user