Retention / GC planner — design
Class:
design· Function:retention(usesregistryfor the per-repolist()listing, the in-processHolgerfacade for the adapter). The whole-system view isdesign.md; the parity gap this closes isfeature-complete-gaps.md§4 (retention/cleanup — previously all ❌, the highest operability hole) andartifactory-parity-design.md§D.
What this is
The retention/GC planner answers the Artifactory/Nexus "retention policy" table stakes: given a repository's artifacts and a policy, which does it keep and which can it reclaim? It ships as server/lib/src/retention.rs — a pure, deterministic function over artifact metadata:
plan_retention(records: &[ArtifactRecord], policy: &RetentionPolicy, now: i64)
-> RetentionPlan
No I/O, no scheduler, no deletion. Given the same (records, policy, now) it always returns the same RetentionPlan — a keep/delete verdict per artifact, each annotated with the RetentionReasons that produced it, plus kept_bytes / reclaimed_bytes roll-ups.
Why a planner, not a deleter (the chosen design)
Holger's storage is immutable, blake3-verified znippy archives — there is no in-archive delete. Retention therefore operates at the archive-set level: the plan decides what should go; carrying it out is dropping whole superseded generations, which belongs to a jera job (holger's existing durable job ledger, already used for promotion), and the decision is historized to skade (the analysis plane). This is exactly the reuse note in the gap doc: "the planner is a pure function over list_repositories() + archive metadata; it should emit jera jobs to perform the drop, and historize the decision into skade, never hand-roll a scheduler."
Keeping the planner a pure function means it is:
- Trivially testable — 14 unit tests pin the algebra (per-name grouping, each policy dimension, their composition, determinism) with zero fixtures.
- Re-runnable — a policy change re-plans instantly; no state to migrate.
- Transport-neutral — the identical function drives the
holger-server retentionCLI dry-run today, and can drive a gRPC verb or the jera executor later, unchanged. - Additive + safe — it touches no existing RBAC/promote/proxy path and, because it deletes nothing, cannot regress storage.
The policy (RON-serializable)
RetentionPolicy {
keep_last_n: Option<usize>, // keep the N newest versions per name
max_age_secs: Option<i64>, // reclaim records older than this
keep_min_per_name: Option<usize>, // floor: never drop below N per name
max_total_bytes: Option<u64>, // store-size quota (oldest-first evict)
protect_latest_per_name: bool, // never drop a name's newest (default true)
}
All dimensions are optional and compose: an artifact is reclaimed if any deletion rule selects it, unless a protection rule saves it. Protection wins ties.
The algorithm
-
Group by
(namespace, name)(aBTreeMap, so the plan is emitted in a stable order). -
Order each group newest-first:
created_atdescending, then a numeric-aware version compare (1.10.0 > 1.9.0), then a stable string tie-break. This meanskeep_last_nworks from a barelist()(name+version) even with no timestamp. -
Per-name rules → an initial verdict per record:
protect_latest_per_nameprotects position 0.keep_min_per_nameprotects positions< min(a floor, independent of the latest-protection).keep_last_nmarks positions>= nsuperseded.max_age_secsmarks any record older than the bound aged-out (needs acreated_at; a record without one is never aged).- A record is deleted iff
(superseded || aged) && !protected.
-
Quota pass (
max_total_bytes): if the kept set still exceeds the bound, evict the kept, non-protected records oldest-first until it fits (or nothing evictable remains — a protected latest survives even an unmeetable quota). -
Assemble the
RetentionPlanwith per-artifact reasons and the byte totals, in the deterministic group / newest-first order.
The executor (the acting side — shipped)
The planner decides; the executor carries a RetentionPlan out. It lives on the Holger facade (I/O side), keeping retention.rs a pure planner:
Holger::execute_repo_retention(repo, &plan, &GcOptions, &dyn AuditLog, jobs) -> GcReport
Holger::run_repo_retention(repo, &policy, now, &GcOptions, &dyn AuditLog, jobs) // plan + execute
Its contract — additive, gated, safe:
- Gated.
GcOptions::dry_run()(the default) previews and drops nothing;GcOptions::execute()is the explicit opt-in that actually reclaims. The CLI keeps dry-run as the default verb and reclaims only under--execute. - Never a protected/kept generation. Only
decision.deleterows are in scope, and — defense in depth against a hand-built or corrupted plan — any delete row still carrying aProtectedLatest/MinPerNameFloorreason is refused (skipped). A protected latest can never be dropped through this path. - Content-id verified. Before any unlink the executor reads the stored bytes, computes their SHA-256, and hands it to the backend's
delete_artifact(id, expected_content_id), which recomputes the on-disk digest and unlinks only on a match — a store that changed under the plan is refused, never deleted blind. On the immutable znippy backendsdelete_artifactfails closed (no in-place delete; whole-generation archive-set drop is out of band); the writable loose-file backends implement the safe unlink. - Historized. Each actual reclaim appends an
AuditAction::Deleteevent (identityholger-retention-gc, reclaimed byte count, plan reasons) to the append-only Arrow audit log — the warehouse / skade GC record. The whole run rides the shared jera job ledger (retention-gc,running → done|failed), reusing the same durable job seam promotion uses.
The returned GcReport carries a per-artifact GcAction (outcome + verified content id) plus the deleted/would_delete/skipped/failed roll-ups and reclaimed_bytes.
Surfaces
- Library —
server_lib::retention::plan_retention(pure) +Holger::plan_repo_retention(planner adapter) +Holger::execute_repo_retention/run_repo_retention(the executor). - CLI —
holger-server retention --config <ron> [--repo r] [--keep-last-n N] [--max-age-secs S] [--keep-min-per-name N] [--max-total-bytes B] [--no-protect-latest] [--execute] [--verbose]. Dry-run by default (prints the KEEP/RECLAIM plan, deletes nothing);--executecarries the plan out and historizes the GC.
Deferred (logged to holger_agents_problems.md)
- Per-artifact ingest time.
ArtifactEntry/list()carry nocreated_attoday, somax_age_secsno-ops over the live adapter (the planner honours it when a caller supplies times, and the tests cover that path). Surfacing archive generation / mtime as the record time is the follow-up that lights up age policies end to end. - Whole-generation archive-set drop. The executor's per-artifact unlink is implemented for the writable loose-file backends; on the immutable znippy backends
delete_artifactfails closed. Dropping a whole superseded znippy generation (re-seal without the reclaimed set) is the archive-level follow-up. - gRPC verb. A
RetentionService.{PlanRetention,ExecuteRetention}(proto + regen) mirrors the CLI for remote operators.