Merge #868 review fixes: recovered fully-acked deployments seal, stashed acks beat a past deadline, failed ack writes retry
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
+111
@@ -115,6 +115,105 @@ public sealed class ConfigPublishCoordinatorDbOutageTests : ControlPlaneActorTes
|
||||
}, duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review finding 1: a recovered deployment whose every expected ack is already in the DB (a seal whose
|
||||
/// SaveChanges failed, or a fused node's own row) must be completed — inside the deadline it used to wait for
|
||||
/// acks that had already landed, past it the deadline's "race" guard returned without sealing; either way it
|
||||
/// sat in AwaitingApplyAcks forever and AdminOps refused every later deploy.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, false, DeploymentStatus.Sealed)]
|
||||
[InlineData(true, false, DeploymentStatus.Sealed)]
|
||||
[InlineData(false, true, DeploymentStatus.PartiallyFailed)]
|
||||
[InlineData(true, true, DeploymentStatus.PartiallyFailed)]
|
||||
public void Recovered_deployment_with_every_ack_already_persisted_is_completed(bool pastDeadline, bool oneFailed, DeploymentStatus expected)
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
SeedNodes(dbFactory, Node, "site-a-2:4053");
|
||||
var deadline = TimeSpan.FromMinutes(1);
|
||||
var deploymentId = SeedDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks,
|
||||
pastDeadline ? DateTime.UtcNow.AddMinutes(-10) : DateTime.UtcNow);
|
||||
SeedNodeState(dbFactory, deploymentId, Node, NodeDeploymentStatus.Applied);
|
||||
SeedNodeState(dbFactory, deploymentId, "site-a-2:4053", oneFailed ? NodeDeploymentStatus.Failed : NodeDeploymentStatus.Applied);
|
||||
|
||||
Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, deadline));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(expected);
|
||||
}, duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review finding 2: a recovered deployment that is already past its deadline must first apply the acks that
|
||||
/// were queued while the load was failing (they arrived in time); the deadline used to run inline before the
|
||||
/// replay, so they were discarded as stale and the deployment was timed out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ack_queued_during_the_outage_is_applied_before_a_past_deadline_recovery_times_out()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
var direct = NewInMemoryDbFactory(dbName);
|
||||
var outage = new OutageDbFactory(NewInMemoryDbFactory(dbName)) { Down = true };
|
||||
SeedNodes(direct, Node);
|
||||
var deploymentId = SeedDeployment(direct, DeploymentStatus.AwaitingApplyAcks, DateTime.UtcNow.AddMinutes(-10));
|
||||
SeedNodeState(direct, deploymentId, Node, NodeDeploymentStatus.Applying);
|
||||
|
||||
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(
|
||||
outage, TimeSpan.FromMinutes(1), loadRetryMin: RetryMin, loadRetryMax: RetryMax));
|
||||
AwaitAssert(() => outage.Attempts.ShouldBeGreaterThanOrEqualTo(2),
|
||||
duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
coordinator.Tell(new ApplyAck(deploymentId, NodeId.Parse(Node), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
outage.Down = false;
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = direct.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
||||
db.NodeDeploymentStates.Single().Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
}, duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review finding 3: an ack whose persist fails (ConfigDb blip while Ready) is retried, not dropped. It used to
|
||||
/// throw, restart the actor and lose the ack — the node never resends and never writes its own row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ack_whose_persist_fails_is_retried_not_dropped()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString("N");
|
||||
var direct = NewInMemoryDbFactory(dbName);
|
||||
var outage = new OutageDbFactory(NewInMemoryDbFactory(dbName));
|
||||
SeedNodes(direct, Node);
|
||||
var deploymentId = SeedDeployment(direct, DeploymentStatus.Dispatching, DateTime.UtcNow);
|
||||
var readiness = new ConfigPublishCoordinatorReadiness();
|
||||
|
||||
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(
|
||||
outage, NoTimeout, readiness: readiness, loadRetryMin: RetryMin, loadRetryMax: RetryMax));
|
||||
coordinator.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = direct.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
|
||||
}, duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
outage.Down = true;
|
||||
var before = outage.Attempts;
|
||||
coordinator.Tell(new ApplyAck(deploymentId, NodeId.Parse(Node), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
AwaitAssert(() => outage.Attempts.ShouldBeGreaterThanOrEqualTo(before + 2),
|
||||
duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
outage.Down = false;
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = direct.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
|
||||
db.NodeDeploymentStates.Single().Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
}, duration: PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Readiness_is_NotHosted_after_the_coordinator_stops()
|
||||
{
|
||||
@@ -146,6 +245,18 @@ public sealed class ConfigPublishCoordinatorDbOutageTests : ControlPlaneActorTes
|
||||
return id;
|
||||
}
|
||||
|
||||
private static void SeedNodeState(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentId deploymentId, string nodeId, NodeDeploymentStatus status)
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.NodeDeploymentStates.Add(new NodeDeploymentState
|
||||
{
|
||||
NodeId = nodeId, DeploymentId = deploymentId.Value, Status = status,
|
||||
AppliedAtUtc = status == NodeDeploymentStatus.Applying ? null : DateTime.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodeIds)
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
|
||||
Reference in New Issue
Block a user