fix(controlplane): retention keep window counts Sealed rows; the newest Sealed blob is never pruned

The window used to count every terminal row, so 50+ failed deploys pushed the last good artifact out of it.
Now the newest KeepSealed (>= 1) Sealed rows keep their blob, failed rows are prunable unless referenced in
the ReferencedDays window. Pinned: 60 TimedOut + 1 old Sealed -> Sealed retained, 60 TimedOut emptied.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:39:34 -04:00
parent 88268ea3a6
commit f1c52385ed
3 changed files with 83 additions and 10 deletions
@@ -97,8 +97,9 @@ public sealed class DeploymentRetentionActor : ReceiveActor, IWithTimers
}
/// <summary>
/// The retention rule. Empties the blob of every terminal deployment that is (a) older than the
/// newest <see cref="DeploymentRetentionOptions.KeepSealed"/> terminal rows, (b) not referenced by a
/// The retention rule. Empties the blob of every terminal deployment that is (a) not one of the
/// newest <see cref="DeploymentRetentionOptions.KeepSealed"/> SEALED rows (at least one — the newest
/// Sealed blob is never pruned), (b) not referenced by a
/// <c>NodeDeploymentState</c> row started or applied within
/// <see cref="DeploymentRetentionOptions.ReferencedDays"/>, and (c) still carrying bytes — at most
/// <see cref="DeploymentRetentionOptions.MaxRowsPerSweep"/> per call.
@@ -125,11 +126,23 @@ public sealed class DeploymentRetentionActor : ReceiveActor, IWithTimers
|| d.Status == DeploymentStatus.PartiallyFailed
|| d.Status == DeploymentStatus.TimedOut)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => new { d.DeploymentId, HasBlob = d.ArtifactBlob.Length > 0 })
.Select(d => new { d.DeploymentId, d.Status, HasBlob = d.ArtifactBlob.Length > 0 })
.ToListAsync(ct)
.ConfigureAwait(false);
var aged = terminal.Skip(keep).Where(d => d.HasBlob).Select(d => d.DeploymentId).ToList();
// The keep window counts SEALED rows only (fix round 1 / I3). Counting every terminal row let a burst
// of failed deploys (TimedOut / PartiallyFailed) fill the window by itself and push the fleet's last
// good artifact — the newest Sealed row, which boot, drift detection and the publish fallback all
// read — out of it. `keep` is clamped to ≥ 1, so the newest Sealed row is never a candidate.
var keptSealed = terminal
.Where(d => d.Status == DeploymentStatus.Sealed)
.Take(keep)
.Select(d => d.DeploymentId)
.ToHashSet();
var aged = terminal
.Where(d => d.HasBlob && !keptSealed.Contains(d.DeploymentId))
.Select(d => d.DeploymentId)
.ToList();
if (aged.Count == 0) return 0;
var referenced = (await db.NodeDeploymentStates
@@ -17,9 +17,10 @@ public sealed class DeploymentRetentionOptions
public bool Enabled { get; set; } = true;
/// <summary>
/// How many of the most recent terminal (Sealed / PartiallyFailed / TimedOut) deployments keep their
/// blob regardless of age. A node booting from central SQL reads the LATEST sealed row's blob, so this
/// must stay well above 1; 50 also keeps a comfortable re-deploy history.
/// How many of the most recent SEALED deployments keep their blob regardless of age (clamped to ≥ 1).
/// Failed deploys (PartiallyFailed / TimedOut) do not count toward it, so a burst of them can never push
/// the last good artifact out. A node booting from central SQL reads the LATEST sealed row's blob, so
/// this must stay well above 1; 50 also keeps a comfortable re-deploy history.
/// </summary>
public int KeepSealed { get; set; } = 50;