Skip to content

Prune expired releases and ref-count their snapshots in retention

Placeholder ppxd requested to merge feat/release-retention into main

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, RetentionPolicyEnforcer now prunes a Release only 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's ReleaseSelectedPackage rows and ref-count-GCs its DeploymentProcessSnapshot / 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) and Deployment.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) and Deployment ({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 deployment subquery 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: LoadDeploymentDataPhase now throws DeploymentEntityNotFoundException for a missing release instead of a NullReferenceException, 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): GetReleasesExceedingRetention matrix — 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.

Merge request reports

Loading