Phase 6.3 Stream A — RedundancyTopology + ClusterTopologyLoader + RedundancyCoordinator
Lands the data path that feeds the Phase 6.3 ServiceLevelCalculator shipped in PR #89. OPC UA node wiring (ServiceLevel variable + ServerUriArray + RedundancySupport) still deferred to task #147; peer-probe loops (Stream B.1/B.2 runtime layer beyond the calculator logic) deferred. Server.Redundancy additions: - RedundancyTopology record — immutable snapshot (ClusterId, SelfNodeId, SelfRole, Mode, Peers[], SelfApplicationUri). ServerUriArray() emits the OPC UA Part 4 §6.6.2.2 shape (self first, peers lexicographically by NodeId). RedundancyPeer record with per-peer Host/OpcUaPort/DashboardPort/ ApplicationUri so the follow-up peer-probe loops know where to probe. - ClusterTopologyLoader — pure fn from ServerCluster + ClusterNode[] to RedundancyTopology. Enforces Phase 6.3 Stream A.1 invariants: * At least one node per cluster. * At most 2 nodes (decision #83, v2.0 cap). * Every node belongs to the target cluster. * Unique ApplicationUri across the cluster (OPC UA Part 4 trust pin, decision #86). * At most 1 Primary per cluster in Warm/Hot modes (decision #84). * Self NodeId must be a member of the cluster. Violations throw InvalidTopologyException with a decision-ID-tagged message so operators know which invariant + what to fix. - RedundancyCoordinator singleton — holds the current topology + IsTopologyValid flag. InitializeAsync throws on invariant violation (startup fails fast). RefreshAsync logs + flips IsTopologyValid=false (runtime won't tear down a running server; ServiceLevelCalculator falls to InvalidTopology band = 2 which surfaces the problem to clients without crashing). CAS-style swap via Volatile.Write so readers always see a coherent snapshot. Tests (10 new ClusterTopologyLoaderTests): - Single-node standalone loads + empty peer list. - Two-node cluster loads self + peer. - ServerUriArray puts self first + peers sort lexicographically. - Empty-nodes throws. - Self-not-in-cluster throws. - Three-node cluster rejected with decision #83 message. - Duplicate ApplicationUri rejected with decision #86 shape reference. - Two Primaries in Warm mode rejected (decision #84 + runtime-band reference). - Cross-cluster node rejected. - None-mode allows any role mix (standalone clusters don't enforce Primary count). Full solution dotnet test: 1178 passing (was 1168, +10). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function mapper from the shared config DB's <see cref="ServerCluster"/> +
|
||||
/// <see cref="ClusterNode"/> rows to an immutable <see cref="RedundancyTopology"/>.
|
||||
/// Validates Phase 6.3 Stream A.1 invariants and throws
|
||||
/// <see cref="InvalidTopologyException"/> on violation so the coordinator can fail startup
|
||||
/// fast with a clear message rather than boot into an ambiguous state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stateless — the caller owns the DB round-trip + hands rows in. Keeping it pure makes
|
||||
/// the invariant matrix testable without EF or SQL Server.
|
||||
/// </remarks>
|
||||
public static class ClusterTopologyLoader
|
||||
{
|
||||
/// <summary>Build a topology snapshot for the given self node. Throws on invariant violation.</summary>
|
||||
public static RedundancyTopology Load(string selfNodeId, ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId);
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
|
||||
ValidateClusterShape(cluster, nodes);
|
||||
ValidateUniqueApplicationUris(nodes);
|
||||
ValidatePrimaryCount(cluster, nodes);
|
||||
|
||||
var self = nodes.FirstOrDefault(n => string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidTopologyException(
|
||||
$"Self node '{selfNodeId}' is not a member of cluster '{cluster.ClusterId}'. " +
|
||||
$"Members: {string.Join(", ", nodes.Select(n => n.NodeId))}.");
|
||||
|
||||
var peers = nodes
|
||||
.Where(n => !string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(n => new RedundancyPeer(
|
||||
NodeId: n.NodeId,
|
||||
Role: n.RedundancyRole,
|
||||
Host: n.Host,
|
||||
OpcUaPort: n.OpcUaPort,
|
||||
DashboardPort: n.DashboardPort,
|
||||
ApplicationUri: n.ApplicationUri))
|
||||
.ToList();
|
||||
|
||||
return new RedundancyTopology(
|
||||
ClusterId: cluster.ClusterId,
|
||||
SelfNodeId: self.NodeId,
|
||||
SelfRole: self.RedundancyRole,
|
||||
Mode: cluster.RedundancyMode,
|
||||
Peers: peers,
|
||||
SelfApplicationUri: self.ApplicationUri);
|
||||
}
|
||||
|
||||
private static void ValidateClusterShape(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
throw new InvalidTopologyException($"Cluster '{cluster.ClusterId}' has zero nodes.");
|
||||
|
||||
// Decision #83 — v2.0 caps clusters at two nodes.
|
||||
if (nodes.Count > 2)
|
||||
throw new InvalidTopologyException(
|
||||
$"Cluster '{cluster.ClusterId}' has {nodes.Count} nodes. v2.0 supports at most 2 nodes per cluster (decision #83).");
|
||||
|
||||
// Every node must belong to the given cluster.
|
||||
var wrongCluster = nodes.FirstOrDefault(n =>
|
||||
!string.Equals(n.ClusterId, cluster.ClusterId, StringComparison.OrdinalIgnoreCase));
|
||||
if (wrongCluster is not null)
|
||||
throw new InvalidTopologyException(
|
||||
$"Node '{wrongCluster.NodeId}' belongs to cluster '{wrongCluster.ClusterId}', not '{cluster.ClusterId}'.");
|
||||
}
|
||||
|
||||
private static void ValidateUniqueApplicationUris(IReadOnlyList<ClusterNode> nodes)
|
||||
{
|
||||
var dup = nodes
|
||||
.GroupBy(n => n.ApplicationUri, StringComparer.Ordinal)
|
||||
.FirstOrDefault(g => g.Count() > 1);
|
||||
if (dup is not null)
|
||||
throw new InvalidTopologyException(
|
||||
$"Nodes {string.Join(", ", dup.Select(n => n.NodeId))} share ApplicationUri '{dup.Key}'. " +
|
||||
$"OPC UA Part 4 requires unique ApplicationUri per server — clients pin trust here (decision #86).");
|
||||
}
|
||||
|
||||
private static void ValidatePrimaryCount(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
|
||||
{
|
||||
// Standalone mode: any role is fine. Warm / Hot: at most one Primary per cluster.
|
||||
if (cluster.RedundancyMode == RedundancyMode.None) return;
|
||||
|
||||
var primaries = nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
if (primaries > 1)
|
||||
throw new InvalidTopologyException(
|
||||
$"Cluster '{cluster.ClusterId}' has {primaries} Primary nodes in redundancy mode {cluster.RedundancyMode}. " +
|
||||
$"At most one Primary per cluster (decision #84). Runtime detects and demotes both to ServiceLevel 2 " +
|
||||
$"per the 8-state matrix; startup fails fast to surface the misconfiguration earlier.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user