perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -277,6 +277,89 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return expired.Count;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.DeploymentRecords.AsNoTracking();
if (instanceId.HasValue)
query = query.Where(d => d.InstanceId == instanceId.Value);
if (status.HasValue)
query = query.Where(d => d.Status == status.Value);
if (instanceIdScope != null)
{
// Site scoping applied DB-side. An empty scope is a real, meaningful
// filter — a user permitted no in-scope instances sees nothing — so it
// must not be short-circuited into "no filter".
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
query = query.Where(d => scope.Contains(d.InstanceId));
}
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
// pages).
var paged = query
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
return await paged
.Select(d => new DeploymentRecordSummary(
d.Id,
d.DeploymentId,
d.InstanceId,
d.Status,
d.RevisionHash,
d.DeployedBy,
d.DeployedAt,
d.CompletedAt,
d.ErrorMessage))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
DateTimeOffset cutoffUtc,
int batchSize,
CancellationToken cancellationToken = default)
{
// Terminal statuses ONLY. An InProgress (or Pending) row is never purged
// regardless of age: it is precisely the row TryReconcileWithSiteAsync
// looks for when deciding whether a prior deploy actually landed, and
// deleting it would silently disable the query-before-redeploy idempotency
// guard for that instance.
//
// Bounded batch rather than one unbounded DELETE so the first purge on a
// long-lived system cannot take a table-scale lock on DeploymentRecords.
// The caller re-invokes until it returns fewer rows than the batch size.
var stale = await _dbContext.DeploymentRecords
.Where(d => d.CompletedAt != null
&& d.CompletedAt < cutoffUtc
&& (d.Status == DeploymentStatus.Success || d.Status == DeploymentStatus.Failed))
.OrderBy(d => d.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
_dbContext.DeploymentRecords.RemoveRange(stale);
await _dbContext.SaveChangesAsync(cancellationToken);
return stale.Count;
}
// --- Startup reconciliation ---
/// <inheritdoc />