Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

.nornir/retention-design.md

Retention / GC planner — design

Class: design · Function: retention (uses registry for the per-repo list() listing, the in-process Holger facade for the adapter). The whole-system view is design.md; the parity gap this closes is feature-complete-gaps.md §4 (retention/cleanup — previously all ❌, the highest operability hole) and artifactory-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:

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

  1. Group by (namespace, name) (a BTreeMap, so the plan is emitted in a stable order).

  2. Order each group newest-first: created_at descending, then a numeric-aware version compare (1.10.0 > 1.9.0), then a stable string tie-break. This means keep_last_n works from a bare list() (name+version) even with no timestamp.

  3. Per-name rules → an initial verdict per record:

    • protect_latest_per_name protects position 0.
    • keep_min_per_name protects positions < min (a floor, independent of the latest-protection).
    • keep_last_n marks positions >= n superseded.
    • max_age_secs marks any record older than the bound aged-out (needs a created_at; a record without one is never aged).
    • A record is deleted iff (superseded || aged) && !protected.
  4. 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).

  5. Assemble the RetentionPlan with 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:

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

Deferred (logged to holger_agents_problems.md)