feat(fleet): reconcile ClusterNode dial targets against live membership

ClusterNode.AkkaPort and the node's own Cluster:Port are the same fact in two
places and nothing made them agree. Phase 2 dials the row instead of gossiping,
so a node binding 4054 while its row says 4053 becomes unreachable from central
— and the symptom is a silent absence of acks rather than an error. That is the
shape of the Modbus/ModbusTcp and TwinCat/Focas drifts already in this repo.
Since Phase 1 also made the rows the deploy path's expected-ack set, drift
already costs a failed deployment today.

Implemented as the plan's preferred option: an admin-role singleton comparing
rows against the membership an admin node can already see, rather than each
driver node asserting its own row — Phase 4 removes the driver nodes' ConfigDb
connection, so a self-assertion written there would have to be deleted again.

Three shapes, split by severity: a row whose dial target disagrees with its own
NodeId and a running node with no row are Errors; an enabled row with no
matching member is a Warning, because a node down for maintenance is a
legitimate state. Findings are logged only when the set changes — a check that
reprints the same warning every sweep trains operators to filter it out.

Documented limitation: Phase 2 must revisit this. Once the fleet splits into
one mesh per cluster an admin node cannot see site members, and every site row
would report EnabledRowNotInCluster forever.

Sabotage: removing the change-detection guard turns the repeat test red.
ControlPlane.Tests 100/100.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 08:36:44 -04:00
parent d88e245503
commit 90c1302f71
6 changed files with 511 additions and 3 deletions
@@ -0,0 +1,137 @@
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
/// <summary>What kind of disagreement was found between a node's real address and its row.</summary>
public enum AddressMismatchKind
{
/// <summary>
/// The row was matched to a live member by <c>NodeId</c>, but its <c>Host</c> / <c>AkkaPort</c>
/// columns name a different address. Phase 2 dials those columns, so central would dial
/// somewhere the node is not — and the symptom there is a silent absence of acks.
/// </summary>
RowDialTargetDisagrees,
/// <summary>
/// A driver-role member is in the cluster with no enabled <c>ClusterNode</c> row at its
/// address. Its deployment acks are discarded (see <c>ConfigPublishCoordinator</c>), so it
/// silently receives deployments it is never credited for.
/// </summary>
RunningNodeHasNoRow,
/// <summary>
/// An enabled row names a node that is not currently a driver-role member. Either it is down
/// — in which case the next deployment fails at the apply deadline — or it binds an address
/// other than the one its row claims.
/// </summary>
EnabledRowNotInCluster,
}
/// <summary>One disagreement, with enough detail to act on without opening the DB.</summary>
/// <param name="Kind">Which disagreement this is.</param>
/// <param name="NodeId">The <c>host:port</c> identity the finding is about.</param>
/// <param name="Detail">Human-readable specifics, including what to do about it.</param>
public sealed record ClusterNodeAddressMismatch(AddressMismatchKind Kind, string NodeId, string Detail);
/// <summary>A <c>ClusterNode</c> row reduced to the address facts this check cares about.</summary>
/// <param name="NodeId">The row's primary key, in the <c>host:port</c> space.</param>
/// <param name="Host">The host central will dial in Phase 2.</param>
/// <param name="AkkaPort">The remoting port central will dial in Phase 2.</param>
public sealed record ClusterNodeAddress(string NodeId, string Host, int AkkaPort);
/// <summary>
/// Compares the addresses driver nodes actually occupy against the <c>ClusterNode</c> rows that
/// claim to describe them.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> <c>ClusterNode.AkkaPort</c> and the node's own <c>Cluster:Port</c>
/// are the same fact stored in two places, and nothing makes them agree. A node that binds
/// 4054 while its row says 4053 is unreachable from central once Phase 2 dials the row
/// instead of gossiping — and an unenforced duplicated address is the exact shape of the
/// <c>Modbus</c>/<c>ModbusTcp</c> and <c>TwinCat</c>/<c>Focas</c> drifts already recorded in
/// this repo. Since Phase 1 also made the rows the deploy path's expected-ack set, a drifted
/// row already costs a failed deployment today.
/// </para>
/// <para>
/// <b>It runs on an admin node</b>, comparing rows against the membership an admin node can
/// already see, rather than having each driver node assert its own row — Phase 4 removes the
/// driver nodes' ConfigDb connection entirely, so a self-assertion written there would have
/// 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.
/// </para>
/// </remarks>
public static class ClusterNodeAddressReconciler
{
/// <summary>
/// Reconciles observed driver-role member addresses against the configured rows.
/// </summary>
/// <param name="observedDriverMembers">
/// <c>host</c>/<c>port</c> of every Up member carrying the <c>driver</c> role. Admin-only
/// members must be excluded — they legitimately have no <c>ClusterNode</c> row, and including
/// them would report a mismatch for correct configuration.
/// </param>
/// <param name="rows">Every <c>ClusterNode</c> row, enabled or not.</param>
/// <param name="enabledNodeIds">
/// The subset of <paramref name="rows"/> that is enabled. Disabled rows are deliberately
/// still matched against membership (a disabled row for a running node is worth knowing) but
/// never reported as <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> — being absent
/// is the whole point of disabling one.
/// </param>
/// <returns>Every disagreement found, ordered by node id for stable logging.</returns>
public static IReadOnlyList<ClusterNodeAddressMismatch> Reconcile(
IReadOnlyCollection<(string Host, int Port)> observedDriverMembers,
IReadOnlyCollection<ClusterNodeAddress> rows,
IReadOnlyCollection<string> enabledNodeIds)
{
var byNodeId = new Dictionary<string, ClusterNodeAddress>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows) byNodeId[row.NodeId] = row;
var enabled = new HashSet<string>(enabledNodeIds, StringComparer.OrdinalIgnoreCase);
var findings = new List<ClusterNodeAddressMismatch>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (host, port) in observedDriverMembers)
{
var nodeId = $"{host}:{port}";
seen.Add(nodeId);
if (!byNodeId.TryGetValue(nodeId, out var row))
{
findings.Add(new ClusterNodeAddressMismatch(
AddressMismatchKind.RunningNodeHasNoRow, nodeId,
$"driver node is in the cluster at {nodeId} but no ClusterNode row has that NodeId; " +
"its deployment acks are discarded. Either register the row or correct the node's " +
"Cluster:PublicHostname / Cluster:Port to match an existing one"));
continue;
}
if (!string.Equals(row.Host, host, StringComparison.OrdinalIgnoreCase) || row.AkkaPort != port)
{
findings.Add(new ClusterNodeAddressMismatch(
AddressMismatchKind.RowDialTargetDisagrees, nodeId,
$"node is at {nodeId} but its row's dial target is {row.Host}:{row.AkkaPort}; " +
"central will dial the wrong address once Phase 2 stops using gossip. " +
"Correct ClusterNode.Host / ClusterNode.AkkaPort"));
}
}
foreach (var row in rows)
{
if (seen.Contains(row.NodeId) || !enabled.Contains(row.NodeId)) continue;
findings.Add(new ClusterNodeAddressMismatch(
AddressMismatchKind.EnabledRowNotInCluster, row.NodeId,
$"enabled ClusterNode row {row.NodeId} has no matching driver member; the next " +
"deployment will fail at the apply deadline waiting for it. Start the node, or set " +
"ClusterNode.Enabled = 0 while it is down for maintenance"));
}
return findings
.OrderBy(f => f.NodeId, StringComparer.OrdinalIgnoreCase)
.ThenBy(f => f.Kind)
.ToList();
}
}