Prune expired releases and ref-count their snapshots in retention
Summary
- Releases and their content-deduped process/variable snapshots were never pruned by retention (only deployments were), so they grew without bound.
- After the per-environment deployment pruning,
RetentionPolicyEnforcernow prunes aReleaseonly when ALL hold: lifecycle window is finite (ReleaseRetentionKeepForever == false), the release is past the window, not currently deployed, and has no surviving deployments. It cascades the release'sReleaseSelectedPackagerows and ref-count-GCs itsDeploymentProcessSnapshot/VariableSetSnapshot(kept if any surviving release or deployment still references the snapshot). -
Opt-in / non-breaking: gated on
ReleaseRetentionKeepForever, which defaults to true, so nothing is pruned unless an operator configures a finite window. No schema/DTO/signature changes.
Deletion-rule review (global)
Every referencer was enumerated and handled:
-
Release is referenced only by
ReleaseSelectedPackage.ReleaseId(cascaded) andDeployment.ReleaseId(a release with any deployment is never pruned). No other entity, JSON blob, Hangfire arg, cache, or checkpoint references a release id. -
Snapshots are referenced only by
Release(Project{DeploymentProcess,VariableSet}SnapshotId) andDeployment({Process,VariableSet}SnapshotId). Deployments copy the snapshot ids at creation, so an in-flight/re-deployable deployment keeps its own snapshot alive; the GC ref-counts both. No snapshot id lives anywhere else. - Latest-release / dashboard: all release consumers are null-tolerant (dashboard takes top-N, triggers no-op on null); no "newest release per channel" invariant exists, so pruning an old undeployed release breaks nothing.
Safety hardening (from the review)
-
Anti-race: the package/release deletes carry an atomic
NOT EXISTS deploymentsubquery re-evaluated at DELETE time, so a release that gains a deployment between the candidate read and the delete is excluded from both deletes (no orphaned package, no deployment with a pruned release). -
Defensive guard:
LoadDeploymentDataPhasenow throwsDeploymentEntityNotFoundExceptionfor a missing release instead of aNullReferenceException, turning the residual cross-transaction window (and any externally-deleted release) into a clean, logged deployment failure. - Release (the anchor) is deleted last so a mid-sequence crash retries idempotently next run — no permanent orphans.
Test plan
-
Unit ( RetentionPolicyEnforcerTests, +5):GetReleasesExceedingRetentionmatrix — old-undeployed-not-current pruned; currently-deployed kept; has-surviving-deployments kept; recent kept; mixed set prunes only the eligible one. -
Integration ( ReleaseRetentionTests, 6, real Postgres): old undeployed release → release + packages + snapshots gone; recent kept; currently-deployed old release kept; old release with a surviving deployment kept; shared snapshot kept while another surviving release references it (ref-count); KeepForever lifecycle prunes nothing. -
Regression: existing deployment/task-cleanup integration green; full unit suite green (5743); solution builds clean.