Prepared-image pruning is a silent no-op (mis-parsed docker output); fix as last-used, not creation-time #41
Links
-
🔒 private issue
prune_prepared_images(max_age_days, max_count) (src/runner/prepare.rs:173) has two retention problems.
1. It runs only at runner startup
src/runner/loop_runner.rs:75 calls prepare::prune_prepared_images(30, 10) once, before the poller loop. A runner that stays up for weeks — which is the normal case, and the whole point of a stateful worker — never prunes again. Disk grows until someone restarts it, and the 30-day expiry never fires.
2. It evicts by creation date, not last use
images.sort_by(|a, b| b.1.cmp(a.1)); // CreatedAt, newest first
for (tag, _) in images.iter().skip(max_count) { /* docker rmi */ }
So “keep the 10 newest” is by build time. A prepared image built once and used every day for a month is evicted in favour of one built yesterday and never used since. That is backwards for a cache.
The cost is not uniform: anvil-cli’s cross-compilation toolchain (zig + cargo-zigbuild from source + macOS SDK + four rustup targets) takes roughly 25 minutes to rebuild. One unlucky eviction is a 25-minute stall on an otherwise 2-minute job.
Ask
- Prune periodically (e.g. alongside the existing heartbeat), not just at boot.
- Track last-used and evict LRU. Docker does not record this, so the runner needs its own marker — touching a file keyed by tag when
image_exists_locallyhits is enough, and survives restarts if it lives under the runner config dir.
Minor, same function
The age comparison does a lexicographic string compare of Docker’s {{.CreatedAt}} ("2026-07-22 10:06:54 +0000 UTC") against a %Y-%m-%d cutoff. It works while every timestamp shares a timezone and stays > 9 chars, but it is comparing formats that were never guaranteed to align. Parsing the date would be cheaper to reason about than the invariant.
Part of fangorn/anvil#354.
Scoped within epic fangorn/anvil#354 to the LRU / last-used half: keep the expensive (~25-min) prepared image from being evicted by the current creation-time sort, since runners are shared across repos and image churn is real. The ‘prune more than once per process’ disk-hygiene half is worth doing but is not on the epic’s critical path — treat it as a separable follow-up.
Correction + confirmed root cause (raised by a scoping review)
The “minor, same function” note below undersold this. The prune deletes nothing, ever — verified.
prune_prepared_images (src/runner/prepare.rs:173) formats with {{.CreatedAt}}, which Docker renders as a multi-token string — on a real host: 2026-07-22 02:57:58 -0500 CDT. The parser then does:
let parts: Vec<&str> = line.splitn(3, ' ').collect(); // line: "<ID> <date> <time> <tz> <TZ> anvil-prepared:<hash>"
Some((parts[2], parts[1])) // (tag, created_at)
For a real line abc123 2026-07-22 10:06:54 +0000 UTC anvil-prepared:b0f07b5ea862:
parts[1](used as created_at) ="2026-07-22"— clean date, so the age comparison actually works.parts[2](passed todocker rmi) ="10:06:54 +0000 UTC anvil-prepared:b0f07b5ea862"— a ref containing spaces.
.args(["rmi", tag]) passes that whole string as one argv, docker rmi rejects it as an invalid reference, and let _ = swallows the error. Both prune loops (keep-N and age) hit the same garbage tag, so no prepared image is ever removed.
Consequence: unbounded disk growth on every runner, until the disk fills (cf fangorn/anvil#334 — a full runner disk blocked every PR). The 30-day / keep-10 policy has never actually run.
This reframes the fix priority
-
Correctness (new, urgent): make prune work at all. Don’t string-split
{{.CreatedAt}}. Use a delimiter-safe format — e.g.--format '{{.Repository}}:{{.Tag}}\t{{.ID}}'and get the timestamp fromdocker inspect --format '{{.Created}}'(RFC3339, no spaces) or{{.Metadata.LastTagTime}}, splitting on\t. Parse the tag as one field, never by whitespace. -
Scoping (the original ask): evict by last-used, not creation time. Content-addressing means one prepared image is legitimately shared across repos and steps, so retention must not be scoped per repo/step/arch — that would defeat the sharing. It must be scoped by use: keep an image iff something still uses it, regardless of which workload built it or how old it is. Docker doesn’t record last-use, so the runner needs its own marker — touch a file keyed by tag on each
image_exists_locallyhit (under the runner config dir, survives restarts), evict LRU. -
Cadence: prune periodically, not once at boot (
loop_runner.rs:75). A long-lived runner currently never re-prunes — though today that’s moot since the prune is a no-op anyway.
Namespace scoping is already correct: docker images anvil-prepared only ever considers anvil-prepared:*, so base/service/user images are never at risk.
(Everything below predates this correction.)
Fixed by #43 (merged).
Verified on main (src/runner/prepare.rs, src/runner/loop_runner.rs):
- Periodic, not boot-only —
prune_prepared_images(30, 10)runs at startup and on a 6h interval viaspawn_blocking, so a long-lived runner keeps reclaiming disk. - LRU, not creation-time —
touch_prepared_image(&tag)records use (a marker file under the runner dir, surviving restarts) on everyimage_exists_locallyhit and after a build;last_used(tag)feeds the eviction sort, which is nowsort_by_key(|(_, used)| Reverse(*used)). An image built once and used daily now survives. - The lexicographic date compare is gone — retention operates on
SystemTime/Duration(now.duration_since(*used) > max_age), and the policy is unit-tested without touching Docker or the clock.