1aa7905676
Review at HEAD 7286d320. ControlPlane-001 (Medium): ConfigPublishCoordinator.HandleAck
now discards acks from nodes not in _expectedAcks (prevented premature SealDeployment) +
regression test. -002 (flipped-node log count), -003 (redundant mapper arms) tidied.
134 lines
6.1 KiB
C#
134 lines
6.1 KiB
C#
using Akka.Actor;
|
|
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.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
public sealed class ConfigPublishCoordinatorTests : ControlPlaneActorTestBase
|
|
{
|
|
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('a', 64));
|
|
|
|
/// <summary>Verifies that deployment seals immediately when no driver-role members are in the cluster.</summary>
|
|
[Fact]
|
|
public void EmptyCluster_dispatch_seals_immediately()
|
|
{
|
|
// With no driver-role cluster members in scope, the coordinator has nobody to wait for
|
|
// and seals the deployment right after writing the AwaitingApplyAcks status.
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory));
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
var status = db.Deployments.Single().Status;
|
|
status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that stale apply acknowledgments arriving after seal are ignored.</summary>
|
|
[Fact]
|
|
public void Stale_ApplyAck_after_seal_is_ignored()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
|
|
|
// Wait for seal.
|
|
AwaitAssert(() =>
|
|
{
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}, duration: TimeSpan.FromSeconds(3));
|
|
|
|
// Now send a late ApplyAck for the just-sealed deployment. Should be a no-op — neither
|
|
// crash the actor nor modify the row. We give it a beat and re-check the status.
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("ghost-node"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(250));
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression guard for ControlPlane-001: an ApplyAck from a node that was NOT in the expected-ack
|
|
/// set (i.e. not a driver-role member when DispatchDeployment ran) must be discarded.
|
|
/// Without the fix, an unexpected-node ack inflates <c>_acks.Count</c> and can cause the
|
|
/// coordinator to seal a deployment before every expected node has responded.
|
|
///
|
|
/// Scenario: dispatch with zero expected nodes seals immediately (baseline). A truly-unexpected
|
|
/// node later sends an ack for a fresh deployment that HAS one expected node — the ack from the
|
|
/// unexpected node must be ignored so the deployment waits for the real node.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplyAck_from_unexpected_node_is_discarded_and_does_not_seal_prematurely()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
|
|
|
// Seed a NodeDeploymentState row for "expected-driver" so the coordinator sees 1 expected ack.
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
db.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState
|
|
{
|
|
NodeId = "expected-driver",
|
|
DeploymentId = deploymentId.Value,
|
|
Status = Configuration.Enums.NodeDeploymentStatus.Applying,
|
|
});
|
|
db.SaveChanges();
|
|
}
|
|
|
|
// Long deadline so time does not confound the test.
|
|
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, TimeSpan.FromMinutes(5)));
|
|
|
|
// Drive the coordinator into the AwaitingApplyAcks state via DispatchDeployment.
|
|
// The coordinator seeds expected-acks from _cluster.State.Members (filtered to driver role).
|
|
// In this test harness the cluster has no driver-role members, so _expectedAcks is empty
|
|
// and the coordinator seals immediately on DispatchDeployment.
|
|
//
|
|
// To test the discard logic we use the PreStart recovery path instead: start the coordinator
|
|
// WITHOUT dispatching so it recovers the in-flight deployment from the DB (the NodeDeploymentState
|
|
// row seeds _expectedAcks = {"expected-driver"}), then send an ack from an UNEXPECTED node.
|
|
// If the bug is present the count check fires and seals; with the fix the ack is discarded.
|
|
|
|
// Send ack from a rogue node NOT in _expectedAcks.
|
|
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("rogue-node"),
|
|
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
|
|
|
// Give it time to process and potentially seal if buggy.
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
|
|
|
using var db2 = dbFactory.CreateDbContext();
|
|
var status = db2.Deployments.Single().Status;
|
|
// Deployment must still be AwaitingApplyAcks (or Dispatching from seed) — NOT Sealed or PartiallyFailed.
|
|
status.ShouldNotBe(DeploymentStatus.Sealed);
|
|
status.ShouldNotBe(DeploymentStatus.PartiallyFailed);
|
|
}
|
|
|
|
private static DeploymentId SeedDispatchingDeployment(
|
|
Microsoft.EntityFrameworkCore.IDbContextFactory<Configuration.OtOpcUaConfigDbContext> dbFactory)
|
|
{
|
|
var id = DeploymentId.NewId();
|
|
using var db = dbFactory.CreateDbContext();
|
|
db.Deployments.Add(new Configuration.Entities.Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = TestRevision.Value,
|
|
Status = DeploymentStatus.Dispatching,
|
|
CreatedBy = "test",
|
|
});
|
|
db.SaveChanges();
|
|
return id;
|
|
}
|
|
}
|