fix(deployment): pending-deployment supersede/purge/stage deletes never read ConfigurationJson; drop stale deferred-TODO comment (review 2026-10-21 08/P6, 08/C2)

This commit is contained in:
Joseph Doherty
2026-10-22 06:18:32 -04:00
parent 0d4f7d33b9
commit da5a049077
3 changed files with 192 additions and 25 deletions
@@ -339,12 +339,19 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
// Supersession: a newer deploy for the same instance replaces any prior pending
// row. Safe because the per-instance operation lock serializes same-instance
// deploys. SaveChanges is deferred to the caller, matching the other Add methods.
var prior = await _dbContext.Set<PendingDeployment>()
//
// Only the prior rows' KEYS are read: each row carries the whole flattened
// ConfigurationJson (tens of KB), and materialising it just to delete it was the
// dominant cost of the supersede. The delete itself stays a tracked Remove (not
// ExecuteDelete) so it still commits atomically with the new row in the caller's
// SaveChanges.
var priorIds = await _dbContext.Set<PendingDeployment>()
.Where(p => p.InstanceId == pending.InstanceId)
.Select(p => p.Id)
.ToListAsync(cancellationToken);
if (prior.Count > 0)
foreach (var id in priorIds)
{
_dbContext.Set<PendingDeployment>().RemoveRange(prior);
RemovePendingByKey(id);
}
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
}
@@ -395,16 +402,61 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
public async Task<int> PurgeExpiredPendingDeploymentsAsync(DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
{
// Self-contained TTL maintenance op: commits its own delete and returns the count.
var expired = await _dbContext.Set<PendingDeployment>()
.Where(p => p.ExpiresAtUtc <= nowUtc)
.ToListAsync(cancellationToken);
if (expired.Count == 0)
// A set-based DELETE — the expired rows' ConfigurationJson is never read.
return await DeletePendingWhereAsync(p => p.ExpiresAtUtc <= nowUtc, cancellationToken);
}
/// <summary>
/// Deletes every pending row matching <paramref name="predicate"/> immediately, without
/// materialising the rows (each carries its full flattened <c>ConfigurationJson</c>).
/// A relational provider issues one set-based <c>DELETE</c>; a non-relational provider
/// (the EF in-memory provider some host-level tests run on) cannot translate
/// <c>ExecuteDelete</c>, so it falls back to load-and-remove with its own save.
/// </summary>
/// <param name="predicate">Rows to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of rows deleted.</returns>
private async Task<int> DeletePendingWhereAsync(
System.Linq.Expressions.Expression<Func<PendingDeployment, bool>> predicate,
CancellationToken cancellationToken)
{
var query = _dbContext.Set<PendingDeployment>().Where(predicate);
if (_dbContext.Database.IsRelational())
{
return await query.ExecuteDeleteAsync(cancellationToken);
}
var rows = await query.ToListAsync(cancellationToken);
if (rows.Count == 0)
{
return 0;
}
_dbContext.Set<PendingDeployment>().RemoveRange(expired);
_dbContext.Set<PendingDeployment>().RemoveRange(rows);
await _dbContext.SaveChangesAsync(cancellationToken);
return expired.Count;
return rows.Count;
}
/// <summary>
/// Marks the pending row with primary key <paramref name="id"/> for deletion on the next
/// <c>SaveChanges</c> without loading it: an already-tracked instance is removed in place,
/// otherwise a key-only stub is attached and removed (EF deletes by key).
/// </summary>
/// <param name="id">Primary key of the pending row.</param>
private void RemovePendingByKey(int id)
{
var set = _dbContext.Set<PendingDeployment>();
var tracked = set.Local.FirstOrDefault(p => p.Id == id);
if (tracked != null)
{
set.Remove(tracked);
return;
}
var stub = new PendingDeployment(
string.Empty, 0, string.Empty, string.Empty, string.Empty,
default, default) { Id = id };
set.Attach(stub);
set.Remove(stub);
}
/// <inheritdoc />
@@ -467,18 +519,15 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
CancellationToken cancellationToken = default)
{
// Treat createdAtUtc as "now". FIRST remove any EXPIRED pending rows for this instance
// (ExpiresAtUtc <= now). Pending rows are only TTL-purged (the periodic purge is still a
// deferred TODO), so an EXPIRED-but-unpurged row would otherwise (a) read as "a deploy is
// in flight" and block staging — handing the node an expired token (HTTP 404) and leaving
// it unhealed — and (b) collide on the DeploymentId UNIQUE index when a reconcile re-stages
// the snapshot's own DeploymentId. Dropping expired rows first fixes both.
var expired = await _dbContext.Set<PendingDeployment>()
.Where(p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc)
.ToListAsync(cancellationToken);
if (expired.Count > 0)
{
_dbContext.Set<PendingDeployment>().RemoveRange(expired);
}
// (ExpiresAtUtc <= now). Pending rows are only TTL-purged — PendingDeploymentPurgeActor
// sweeps them on its PendingDeploymentPurgeInterval cadence — so between sweeps an
// EXPIRED-but-unpurged row would otherwise (a) read as "a deploy is in flight" and block
// staging — handing the node an expired token (HTTP 404) and leaving it unhealed — and
// (b) collide on the DeploymentId UNIQUE index when a reconcile re-stages the snapshot's
// own DeploymentId. Dropping expired rows first fixes both. The delete is set-based and
// immediate (an expired row is dead either way), so its JSON is never read.
await DeletePendingWhereAsync(
p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc, cancellationToken);
// THEN insert-if-absent against still-LIVE rows only. A live pending row means a genuine
// in-flight deploy (or a concurrent reconcile) already owns the slot — do NOT supersede it;
@@ -494,9 +543,9 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
}
// Self-contained: one SaveChanges flushes the expired-row cleanup and, when staged, the new
// row together (EF orders the delete before the same-DeploymentId insert to satisfy the
// unique index). Returns true only when a fresh row was staged.
// Self-contained: the expired-row cleanup has already run, so the same-DeploymentId insert
// below cannot collide with it on the unique index. Returns true only when a fresh row was
// staged.
await _dbContext.SaveChangesAsync(cancellationToken);
return !liveExists;
}