refactor(configdb): drop ClusterNode.RedundancyRole (replaced by Akka leader)

Phase 1d of the v2 entity-model rewrite. The static RedundancyRole column
is replaced by Akka cluster's role-leader-of-"driver" election at runtime
(see RedundancyStateActor + ServiceLevelCalculator in Task 35).

Changes:

  - Removed `public required RedundancyRole RedundancyRole` from
    ClusterNode entity.
  - Removed `e.Property(x => x.RedundancyRole).HasConversion<string>()...`
    mapping from OtOpcUaConfigDbContext.ConfigureClusterNode.
  - Removed the `UX_ClusterNode_Primary_Per_Cluster` filtered unique index
    (filter referenced [RedundancyRole]='Primary').
  - Dropped `using ZB.MOM.WW.OtOpcUa.Configuration.Enums` from ClusterNode.cs
    (no longer needed).
  - Deleted `Enums/RedundancyRole.cs` — the enum is unused in v2-kept code.
  - DraftValidator: dropped the "exactly one Primary per cluster"
    validation block. Comment in place explaining v2 picks primary at
    runtime via Akka.
  - DraftValidatorTests: dropped ValidateClusterTopology_flags_multiple_Primary
    test; reworked BuildNode helper to no longer take a `role` argument.

Untouched (Server + Admin still reference RedundancyRole; accepted broken
per Task 56 policy):

  src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/{ClusterTopologyLoader,
    RedundancyStatePublisher, RedundancyTopology, ServiceLevelCalculator}.cs
  src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs

DB-runtime tests will fail against the new schema (Task 14f's migration
drops the column) — to be updated in Task 14f's SchemaComplianceTests
update:

  - SchemaComplianceTests.cs:55 (expected filtered index list)
  - StoredProceduresTests.cs:263 (raw INSERT names the column)

Verification:
  src/Core/ZB.MOM.WW.OtOpcUa.Configuration            -> 0 errors
  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests    -> 0 errors
  whole solution                                       -> 71 errors
    (70 from Task 14b in Server/Admin, +1 new Server/Redundancy reference)
This commit is contained in:
Joseph Doherty
2026-05-26 04:11:57 -04:00
parent 1ddf8bb50e
commit 3c915e652e
5 changed files with 16 additions and 45 deletions

View File

@@ -1,5 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary> /// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
@@ -10,8 +8,6 @@ public sealed class ClusterNode
public required string ClusterId { get; set; } public required string ClusterId { get; set; }
public required RedundancyRole RedundancyRole { get; set; }
/// <summary>Machine hostname / IP.</summary> /// <summary>Machine hostname / IP.</summary>
public required string Host { get; set; } public required string Host { get; set; }

View File

@@ -1,9 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Per-node redundancy role within a cluster. Per decision #84.</summary>
public enum RedundancyRole
{
Primary,
Secondary,
Standalone,
}

View File

@@ -115,7 +115,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasKey(x => x.NodeId); e.HasKey(x => x.NodeId);
e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.RedundancyRole).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Host).HasMaxLength(255); e.Property(x => x.Host).HasMaxLength(255);
e.Property(x => x.ApplicationUri).HasMaxLength(256); e.Property(x => x.ApplicationUri).HasMaxLength(256);
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)"); e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
@@ -130,10 +129,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
// Fleet-wide unique per decision #86 // Fleet-wide unique per decision #86
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri"); e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId"); e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
// At most one Primary per cluster // v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole
e.HasIndex(x => x.ClusterId).IsUnique() // column it filtered on) are gone. Akka cluster leader-of-driver-role is the
.HasFilter("[RedundancyRole] = 'Primary'") // authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator,
.HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster"); // Task 35).
}); });
} }

View File

@@ -228,14 +228,9 @@ public static class DraftValidator
$"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.", $"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.",
cluster.ClusterId)); cluster.ClusterId));
// Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation // v2: the v1 "exactly one Primary per cluster" invariant is gone. RedundancyRole was
// regardless of mode; catch it here so publish fails loud rather than the runtime // dropped in Task 14d; in v2 the Akka cluster's role-leader-of-"driver" elects the
// demoting both to ServiceLevelBand.InvalidTopology at boot. // primary at runtime, so there is no static configuration to validate here.
var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary);
if (primaryCount > 1)
errors.Add(new("ClusterMultiplePrimary",
$"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.",
cluster.ClusterId));
return errors; return errors;
} }

View File

@@ -161,7 +161,7 @@ public sealed class DraftValidatorTests
{ {
var cluster = BuildCluster(nodeCount: nodeCount, mode: mode); var cluster = BuildCluster(nodeCount: nodeCount, mode: mode);
var nodes = Enumerable.Range(0, enabledNodes) var nodes = Enumerable.Range(0, enabledNodes)
.Select(i => BuildNode($"n-{i}", enabled: true, role: i == 0 ? RedundancyRole.Primary : RedundancyRole.Secondary)) .Select(i => BuildNode($"n-{i}", enabled: true))
.ToList(); .ToList();
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
@@ -175,33 +175,24 @@ public sealed class DraftValidatorTests
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot); var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
var nodes = new[] var nodes = new[]
{ {
BuildNode("primary", enabled: true, role: RedundancyRole.Primary), BuildNode("primary", enabled: true),
BuildNode("backup", enabled: false, role: RedundancyRole.Secondary), BuildNode("backup", enabled: false),
}; };
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch"); errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch");
} }
[Fact] // v2: the "exactly one Primary per cluster" check is gone — Akka cluster's
public void ValidateClusterTopology_flags_multiple_Primary() // role-leader-of-"driver" elects the primary at runtime. The corresponding
{ // ValidateClusterTopology_flags_multiple_Primary test (and the
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot); // ClusterMultiplePrimary error code it asserted) were removed alongside Task 14d.
var nodes = new[]
{
BuildNode("a", enabled: true, role: RedundancyRole.Primary),
BuildNode("b", enabled: true, role: RedundancyRole.Primary),
};
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
errors.ShouldContain(e => e.Code == "ClusterMultiplePrimary");
}
[Fact] [Fact]
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone() public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
{ {
var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None); var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None);
var nodes = new[] { BuildNode("only", enabled: true, role: RedundancyRole.Primary) }; var nodes = new[] { BuildNode("only", enabled: true) };
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
errors.ShouldBeEmpty(); errors.ShouldBeEmpty();
@@ -219,11 +210,10 @@ public sealed class DraftValidatorTests
CreatedBy = "t", CreatedBy = "t",
}; };
private static ClusterNode BuildNode(string id, bool enabled, RedundancyRole role) => new() private static ClusterNode BuildNode(string id, bool enabled) => new()
{ {
NodeId = id, NodeId = id,
ClusterId = "c-test", ClusterId = "c-test",
RedundancyRole = role,
Host = "localhost", Host = "localhost",
OpcUaPort = 4840, OpcUaPort = 4840,
DashboardPort = 5001, DashboardPort = 5001,