ee69caa270
Phase 1 gate step 4 failed: setting Enabled = 0 to take a node out of service returns 422 ClusterEnabledNodeCountMismatch, because DraftValidator.ValidateClusterTopology requires the enabled-node count to equal ServerCluster.NodeCount. On a Warm/Hot pair — every cluster on the rig, and every cluster in the target topology — Enabled can therefore never be the maintenance hatch. The plan called step 4 "the check that makes step 3 acceptable to ship", so Task 3's behaviour change was not shippable as it stood: a node down for maintenance would block every deployment to its cluster. The two rules were each reasonable and contradictory together — the validator reads Enabled as "part of the declared topology", Phase 1 additionally read it as "expect an ack". Split the meanings rather than weaken either rule: Enabled part of the declared topology (validator, untouched) MaintenanceMode expected to participate now (coordinator + reconciler) Rejected alternatives: counting configured rather than enabled nodes (drops the guard against booting a pair into InvalidTopology); downgrading the rule to a warning (weakens a deploy gate for everyone); shipping with no hatch. Sabotage: dropping !n.MaintenanceMode from the coordinator query turns the new test red. It also asserts both nodes remain Enabled, so the fix cannot quietly regress to disabling the row after all. Configuration.Tests 95/95, ControlPlane.Tests 101/101, solution builds clean. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
264 lines
12 KiB
C#
264 lines
12 KiB
C#
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
/// <summary>
|
|
/// Per-cluster mesh Phase 1: the coordinator's expected-ack set comes from enabled
|
|
/// <c>ClusterNode</c> rows, not from <c>Akka.Cluster.State.Members</c> filtered by role.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why every test here seeds ClusterNode rows and asserts a non-empty expected set.</b>
|
|
/// The harness's ActorSystem self-joins with role <c>admin</c> and no <c>driver</c> members,
|
|
/// so the OLD derivation returned an empty set for every one of these scenarios and sealed
|
|
/// immediately. Any test that merely observes "the deployment did not seal" would therefore
|
|
/// have passed against the old code too. Each test below distinguishes the two by requiring
|
|
/// the coordinator to WAIT for a specific node and then seal when exactly that node acks —
|
|
/// behaviour the membership rule cannot produce in this harness at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// Verified by reverting <c>DiscoverDriverNodes</c> to the membership scan: the first three
|
|
/// go red. <see cref="No_enabled_rows_seals_empty"/> stays green under both derivations and
|
|
/// is <b>not</b> a test of the derivation — the harness has no driver members AND no enabled
|
|
/// rows, so both rules produce an empty set. It pins the seal-empty branch and its reworded
|
|
/// reason, nothing more.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneActorTestBase
|
|
{
|
|
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('b', 64));
|
|
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>
|
|
/// An enabled row is expected to ack; the deployment waits for it and seals when it arrives.
|
|
/// This is the DB-derived set doing something the membership set demonstrably cannot here.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Enabled_ClusterNode_rows_are_the_expected_ack_set()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
SeedNodes(dbFactory, ("site-a-1:4053", true, false));
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
// The seeded row must have produced a NodeDeploymentState row — proof the coordinator
|
|
// expected it, independent of whether it later sealed.
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A disabled row is NOT expected, so a deployment seals without it — for a node genuinely
|
|
/// decommissioned rather than temporarily down. <b>This is not the maintenance hatch</b>: the
|
|
/// topology gate rejects a deployment whose enabled-node count differs from the cluster's
|
|
/// <c>NodeCount</c>, so on any 2-node pair clearing <c>Enabled</c> never reaches this code.
|
|
/// See <see cref="MaintenanceMode_rows_are_not_expected_to_ack_while_staying_enabled"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Disabled_ClusterNode_rows_are_not_expected_to_ack()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", false, false));
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
// Only the enabled node is expected — the disabled one gets no NodeDeploymentState row.
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
|
|
// ...and the enabled node's ack alone seals it. If the disabled row were expected this would
|
|
// sit at AwaitingApplyAcks until the deadline.
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An ack from a node with no <c>ClusterNode</c> row is discarded — it is not in the expected
|
|
/// set, so it must not count toward the seal. Under the membership rule a running-but-
|
|
/// unregistered node WAS expected, and then threw FK 547 when its state row was written.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Ack_from_a_node_absent_from_ClusterNode_is_discarded()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
SeedNodes(dbFactory, ("site-a-1:4053", true, false));
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
|
|
// A node nobody registered acks. One expected node, one ack — if it were counted, the
|
|
// coordinator would seal here.
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("ghost-9:4053"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
|
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
|
|
}
|
|
|
|
// Positive control on the wait itself: the real node's ack DOES seal it, so the assertion
|
|
// above measured the discard rather than a coordinator that had simply stopped responding.
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>
|
|
/// No enabled rows at all seals empty. Same outcome as the old empty-membership branch, but
|
|
/// it now means "the fleet has no enabled nodes registered" — a configuration error that
|
|
/// cannot resolve itself — rather than "no driver node has joined yet".
|
|
/// </summary>
|
|
[Fact]
|
|
public void No_enabled_rows_seals_empty()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
SeedNodes(dbFactory, ("site-a-1:4053", false, false), ("site-a-2:4053", false, false));
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
db.NodeDeploymentStates.ShouldBeEmpty();
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The maintenance hatch, and the reason it is a separate flag. The Phase 1 live gate set
|
|
/// <c>Enabled = 0</c> on one node of a 2-node pair and the deployment was rejected outright by
|
|
/// <c>DraftValidator.ValidateClusterTopology</c> (enabled-node count must equal
|
|
/// <c>NodeCount</c>) — so the intended escape hatch could not be used on any pair, which is
|
|
/// every cluster in the target topology. <c>MaintenanceMode</c> excludes a node from the
|
|
/// expected-ack set while leaving it enabled, so the topology gate still passes.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MaintenanceMode_rows_are_not_expected_to_ack_while_staying_enabled()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", true, true));
|
|
|
|
// The topology gate counts Enabled, so both nodes still count — this is what Enabled = 0
|
|
// broke. Assert it here so the fix cannot be "quietly disable it after all".
|
|
using (var check = dbFactory.CreateDbContext())
|
|
{
|
|
check.ClusterNodes.Count(n => n.Enabled).ShouldBe(2);
|
|
}
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
private static void SeedNodes(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
|
params (string NodeId, bool Enabled, bool Maintenance)[] nodes)
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = "SITE-A",
|
|
Name = "Site A",
|
|
Enterprise = "zb",
|
|
Site = "site-a",
|
|
NodeCount = 2,
|
|
RedundancyMode = RedundancyMode.Warm,
|
|
CreatedBy = "test",
|
|
});
|
|
foreach (var (nodeId, enabled, maintenance) in nodes)
|
|
{
|
|
db.ClusterNodes.Add(new ClusterNode
|
|
{
|
|
NodeId = nodeId,
|
|
ClusterId = "SITE-A",
|
|
Host = nodeId.Split(':')[0],
|
|
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
|
|
Enabled = enabled,
|
|
MaintenanceMode = maintenance,
|
|
CreatedBy = "test",
|
|
});
|
|
}
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static DeploymentId SeedDispatchingDeployment(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
|
{
|
|
var id = DeploymentId.NewId();
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = TestRevision.Value,
|
|
Status = DeploymentStatus.Dispatching,
|
|
CreatedBy = "test",
|
|
});
|
|
db.SaveChanges();
|
|
return id;
|
|
}
|
|
}
|