feat(deploy): wire periodic PendingDeployment purge + SQL Server same-id re-stage test

Notify-and-fetch follow-ups:

- PendingDeploymentPurgeActor: a central cluster singleton (not
  readiness-gated, best-effort) that sweeps expired PendingDeployment
  staging rows on CommunicationOptions.PendingDeploymentPurgeInterval
  (default 1h). Modeled on the kpi-history-recorder pattern: self-scheduling
  timer, per-tick DI scope -> IDeploymentManagerRepository, continue-on-error.
  Wired in AkkaHostedService.RegisterCentralActors (manager + proxy + drain);
  resolves the deferred TODO in DeploymentService. Correctness never depends
  on it (supersession bounds rows to <=1/instance; the fetch endpoint enforces
  the TTL), so it is deliberately absent from RequiredSingletonsHealthCheck.

- SQL Server integration test for StagePendingIfAbsentAsync re-staging an
  instance's OWN DeploymentId over an expired row against the real UNIQUE
  index on DeploymentId — confirms EF orders DELETE before INSERT in one
  SaveChanges (SQLite's constraint timing differs from SQL Server's). Plus
  a same-instance supersession variant on real SQL Server.

Tests: 2 TestKit actor tests + 2 SQL Server integration tests (both ran
green against the infra MSSQL container); 235 Communication + 15
PendingDeployment tests pass; Host builds 0 warnings.
This commit is contained in:
Joseph Doherty
2026-06-26 23:19:29 -04:00
parent d9f5fbb664
commit 06f2df4f89
6 changed files with 431 additions and 4 deletions
@@ -756,6 +756,60 @@ akka {{
_actorSystem.ActorOf(kpiHistoryProxyProps, "kpi-history-recorder-proxy");
_logger.LogInformation("KpiHistoryRecorderActor singleton created (not readiness-gated)");
// Notify-and-fetch deploy transport — central singleton that periodically
// reclaims expired (TTL-elapsed) PendingDeployment staging rows. Mirrors the
// kpi-history-recorder singleton pattern above: a ClusterSingletonManager pins
// it to the active central node, a ClusterSingletonProxy gives a stable address,
// and a PhaseClusterLeave graceful-stop task drains the in-flight tick before
// handover. The purge timer self-schedules in PreStart. NOT readiness-gated by
// design: the purge is best-effort hygiene — supersession bounds the table to ≤1
// pending row per instance and the config-fetch endpoint enforces the TTL on read,
// so correctness never depends on it; pending-deployment-purge is therefore
// deliberately absent from RequiredSingletonsHealthCheck. The actor takes the root
// IServiceProvider and opens its own per-tick DI scope (IDeploymentManagerRepository
// is a scoped EF Core service).
var pendingPurgeLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<PendingDeploymentPurgeActor>();
var pendingPurgeCommunicationOptions =
_serviceProvider.GetRequiredService<IOptions<CommunicationOptions>>();
var pendingPurgeSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new PendingDeploymentPurgeActor(
_serviceProvider,
pendingPurgeCommunicationOptions,
pendingPurgeLogger)),
terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
.WithSingletonName("pending-deployment-purge"));
var pendingPurgeSingletonManager =
_actorSystem!.ActorOf(pendingPurgeSingletonProps, "pending-deployment-purge-singleton");
var pendingPurgeShutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem);
pendingPurgeShutdown.AddTask(
Akka.Actor.CoordinatedShutdown.PhaseClusterLeave,
"drain-pending-deployment-purge-singleton",
async () =>
{
try
{
await pendingPurgeSingletonManager.GracefulStop(TimeSpan.FromSeconds(10));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PendingDeploymentPurge singleton did not drain within the graceful-stop "
+ "timeout; falling through to PoisonPill handover");
}
return Akka.Done.Instance;
});
var pendingPurgeProxyProps = ClusterSingletonProxy.Props(
singletonManagerPath: "/user/pending-deployment-purge-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("pending-deployment-purge"));
_actorSystem.ActorOf(pendingPurgeProxyProps, "pending-deployment-purge-proxy");
_logger.LogInformation("PendingDeploymentPurgeActor singleton created (not readiness-gated)");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
}