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>
97 lines
4.6 KiB
C#
97 lines
4.6 KiB
C#
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.");
|
|
}
|
|
}
|