1ce2042e14
Task 3 review: scoping only the rows/enabled DB queries by ClusterId left the observed set (from Cluster.State.Members) filtered by the driver role alone. In the mixed-mesh window — DB scoping live via a cluster role, but the physical Akka mesh not yet split so central still sees foreign members via gossip — a foreign-cluster driver member stays in observed while its row is filtered out, hitting the RunningNodeHasNoRow branch, which logs at ERROR. That traded the Warning-level false positive for a worse Error-level one. Filter observed by the node's own cluster ROLE too. Extracted the projection into a static FilterOwnClusterDriverMembers(members, ownClusterRole) so the membership filter is unit-testable without seeding a live cluster (the actor harness joins as admin only, so observed is always empty). Sourced at registration from IClusterRoleInfo.ClusterRole alongside ClusterId. Null role (legacy, no cluster role) keeps every driver member — reconcile-all, unchanged. Also clarified the ownClusterId doc wording (non-null-and-non-empty). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
152 lines
8.5 KiB
C#
152 lines
8.5 KiB
C#
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>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 scopes
|
|
/// <b>both</b> inputs to this function to the admin node's own cluster: the <c>rows</c>/enabled
|
|
/// set to rows whose <c>ClusterId</c> matches
|
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IClusterRoleInfo.ClusterId"/>, and the
|
|
/// observed membership to members carrying its own
|
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IClusterRoleInfo.ClusterRole"/>
|
|
/// (<c>FilterOwnClusterDriverMembers</c>). Scoping only the rows would swap the
|
|
/// <see cref="AddressMismatchKind.EnabledRowNotInCluster"/> false positive for a worse
|
|
/// Error-level <see cref="AddressMismatchKind.RunningNodeHasNoRow"/> during the mixed-mesh
|
|
/// window, where the DB scope is live but central still sees foreign members via gossip. A
|
|
/// legacy admin node carrying no cluster role (null <c>ClusterId</c>/<c>ClusterRole</c>) still
|
|
/// sees every member via gossip and reconciles the full fleet unchanged. The pure function
|
|
/// below is untouched — scoping happens entirely in the actor's DB queries and its
|
|
/// membership filter.
|
|
/// </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="expectedPresentNodeIds">
|
|
/// The subset of <paramref name="rows"/> that should currently be in the cluster: enabled and
|
|
/// not in maintenance. Excluded 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 or putting it in maintenance.
|
|
/// </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> expectedPresentNodeIds)
|
|
{
|
|
var byNodeId = new Dictionary<string, ClusterNodeAddress>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var row in rows) byNodeId[row.NodeId] = row;
|
|
var expectedPresent = new HashSet<string>(expectedPresentNodeIds, 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) || !expectedPresent.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.MaintenanceMode = 1 while it is down for maintenance"));
|
|
}
|
|
|
|
return findings
|
|
.OrderBy(f => f.NodeId, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(f => f.Kind)
|
|
.ToList();
|
|
}
|
|
}
|