ref:447d028a1188282a9eb111697bcf638d3b07e9fc

fix(runner): prepared-image pruning is a silent no-op; fix + LRU (#43)

Closes part of the prepare epic (fangorn/anvil#354 → fangorn/anvil-cli#41). `prune_prepared_images` formatted with `{{.CreatedAt}}`, which Docker renders with spaces (`2026-07-22 10:06:54 +0000 UTC`). `splitn(3, ' ')` then fed `docker rmi` the string `"10:06:54 +0000 UTC anvil-prepared:<hash>"` — a ref with spaces — so every removal failed and was swallowed. **Verified: the prune has never deleted anything**, so prepared images grow unbounded until the disk fills (cf fangorn/anvil#334). ## Fixes - Format a single field so the tag is never split on a timestamp's spaces. - Evict by **last use**, not build time. One content-addressed image is shared across every repo/step with the same prepare block, so retention keys on use, not age or origin — otherwise a busy repo's churn evicts a quiet repo's daily-used image. Tracked with a marker file per tag under `~/.anvil-runner/prepared-images/`, touched on every hit and build. - Prune every 6h, not only at boot. ## Tests Policy factored into a pure `select_for_prune`, unit-tested: LRU-within-count, coldest-first eviction, stale-age dropping, and the key case — an old-but-still-used image survives. 193 tests pass, clippy + fmt clean.
SHA: 447d028a1188282a9eb111697bcf638d3b07e9fc
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-23 03:39
Parents: 54347b0
2 files changed +184 -42
Type
src/runner/loop_runner.rs +10 −2
@@ -70,9 +70,17 @@
heartbeat::send_once(&config).await?;
eprintln!("Connected. Runner '{}' ready.", config.name);
// Prune prepared images accumulated from past runs — keep 10 most recent,
// drop anything older than 30 days.
// Prune prepared images: keep the 10 most-recently-used, drop anything
// unused for 30+ days. Once at startup, then every 6h — a long-lived runner
// must keep reclaiming disk, not only at boot.
prepare::prune_prepared_images(30, 10);
tokio::spawn(async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(6 * 60 * 60)).await;
// Sync docker/fs work off the async runtime.
let _ = tokio::task::spawn_blocking(|| prepare::prune_prepared_images(30, 10)).await;
}
});
// Counter of busy slots — read by the heartbeat, incremented by each
// poller on job claim, released on RAII drop.
src/runner/prepare.rs +174 −40
@@ -37,6 +37,9 @@
let tag = compute_prepared_tag(image, prepare);
if image_exists_locally(&tag) {
// Record the use so LRU pruning keeps images that are still in service,
// regardless of how long ago they were built.
touch_prepared_image(&tag);
let msg = format!("Using cached prepared image: {tag}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
@@ -162,6 +165,7 @@
return Err(format!("docker commit failed: {stderr}").into());
}
touch_prepared_image(&tag);
let msg = format!("Prepared image ready: {tag}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
@@ -169,62 +173,122 @@
Ok(tag)
}
/// Directory of last-used markers, one file per prepared image, under the
/// runner's config dir so it survives restarts.
fn markers_dir() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".anvil-runner")
.join("prepared-images")
}
/// The marker path for a tag. Keyed on the tag's hash so it is filesystem-safe.
/// Prune old prepared images beyond max_count or older than max_age_days.
pub fn prune_prepared_images(max_age_days: u64, max_count: usize) {
fn marker_path(tag: &str) -> std::path::PathBuf {
let hash = tag.strip_prefix("anvil-prepared:").unwrap_or(tag);
markers_dir().join(format!("{hash}.used"))
}
/// Record that `tag` was used just now. Best-effort — a failure here only
/// degrades LRU accuracy, never a build. Writing the file bumps its mtime,
/// which is what pruning reads; the RFC3339 body is for humans debugging.
fn touch_prepared_image(tag: &str) {
let dir = markers_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let _ = std::fs::write(marker_path(tag), chrono::Utc::now().to_rfc3339());
}
/// Last-used time for a tag: the marker's mtime, or `UNIX_EPOCH` when there is
/// no marker (an image built by an older runner and unused since). Treating
/// those as oldest means they are reclaimed first, which is what we want.
fn last_used(tag: &str) -> std::time::SystemTime {
std::fs::metadata(marker_path(tag))
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
}
/// List the `anvil-prepared:*` images present on this host. Formats a single
/// field so the tag is never split on the spaces inside a `{{.CreatedAt}}`
/// timestamp — the bug that made the old prune a silent no-op.
fn list_prepared_tags() -> Vec<String> {
let output = Command::new("docker")
.args([
"images",
"anvil-prepared",
"--format",
"{{.ID}} {{.CreatedAt}} {{.Repository}}:{{.Tag}}",
"{{.Repository}}:{{.Tag}}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
_ => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut images: Vec<(&str, &str)> = stdout
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 3 {
Some((parts[2], parts[1]))
} else {
None
}
})
.collect();
.map(str::trim)
.filter(|l| l.starts_with("anvil-prepared:"))
.map(String::from)
.collect()
}
// Sort by creation date descending (newest first) — lexicographic on ISO date works
images.sort_by(|a, b| b.1.cmp(a.1));
/// Decide which tags to prune: keep the `max_count` most-recently-used, and
/// additionally drop anything unused for longer than `max_age`. Pure, so the
/// policy is unit-tested without touching Docker or the clock.
fn select_for_prune(
mut images: Vec<(String, std::time::SystemTime)>,
now: std::time::SystemTime,
max_age: Option<std::time::Duration>,
max_count: usize,
) -> Vec<String> {
// Most-recently-used first, so `skip(max_count)` drops the coldest.
images.sort_by_key(|(_, used)| std::cmp::Reverse(*used));
// Remove images beyond max_count
for (tag, _) in images.iter().skip(max_count) {
eprintln!("Pruning old prepared image: {tag}");
let _ = Command::new("docker")
.args(["rmi", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let mut doomed = Vec::new();
for (i, (tag, used)) in images.iter().enumerate() {
let over_count = i >= max_count;
let too_old = max_age
.map(|age| now.duration_since(*used).map(|d| d > age).unwrap_or(false))
.unwrap_or(false);
if over_count || too_old {
doomed.push(tag.clone());
}
}
doomed
}
/// Prune prepared images: keep the `max_count` most-recently-used, and drop any
// Also remove images older than max_age_days
if max_age_days > 0 {
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days as i64);
let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
/// unused for more than `max_age_days`. LRU rather than by build time, so an
/// image still in daily use survives no matter how old it is — and no matter
/// which repo or step built it, since one content-addressed image is shared
/// across all of them.
pub fn prune_prepared_images(max_age_days: u64, max_count: usize) {
let tags = list_prepared_tags();
if tags.is_empty() {
return;
}
let images: Vec<(String, std::time::SystemTime)> = tags
.into_iter()
.map(|t| (t.clone(), last_used(&t)))
.collect();
let max_age =
(max_age_days > 0).then(|| std::time::Duration::from_secs(max_age_days * 24 * 60 * 60));
for (tag, created_at) in &images {
if *created_at < cutoff_str.as_str() {
eprintln!("Pruning expired prepared image: {tag}");
let _ = Command::new("docker")
.args(["rmi", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
for tag in select_for_prune(images, std::time::SystemTime::now(), max_age, max_count) {
eprintln!("Pruning prepared image (LRU): {tag}");
let removed = Command::new("docker")
.args(["rmi", &tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
// Drop the marker only once the image is actually gone, so a failed
// rmi (e.g. image busy) is retried next cycle instead of orphaning it.
if removed {
let _ = std::fs::remove_file(marker_path(&tag));
}
}
}
@@ -267,5 +331,75 @@
let tag1 = compute_prepared_tag("elixir:1.17", &["cmd1".into(), "cmd2".into()]);
let tag2 = compute_prepared_tag("elixir:1.17", &["cmd2".into(), "cmd1".into()]);
assert_ne!(tag1, tag2);
}
use std::time::{Duration, SystemTime};
fn at(now: SystemTime, secs_ago: u64) -> SystemTime {
now - Duration::from_secs(secs_ago)
}
#[test]
fn prune_keeps_most_recently_used_within_count() {
let now = SystemTime::now();
// Note the build order is deliberately NOT the use order: "old-build"
// was used most recently, "new-build" least. LRU must keep by use.
let images = vec![
("anvil-prepared:new-build".to_string(), at(now, 9000)),
("anvil-prepared:old-build".to_string(), at(now, 10)),
];
let doomed = select_for_prune(images, now, None, 1);
// Keep 1 → the most-recently-used ("old-build"); prune the other.
assert_eq!(doomed, vec!["anvil-prepared:new-build".to_string()]);
}
#[test]
fn prune_drops_everything_beyond_count_coldest_first() {
let now = SystemTime::now();
let images = vec![
("anvil-prepared:a".to_string(), at(now, 100)),
("anvil-prepared:b".to_string(), at(now, 200)),
("anvil-prepared:c".to_string(), at(now, 300)),
];
let mut doomed = select_for_prune(images, now, None, 1);
doomed.sort();
// Keep the single warmest (a); b and c go.
assert_eq!(doomed, vec!["anvil-prepared:b", "anvil-prepared:c"]);
}
#[test]
fn prune_drops_stale_even_within_count() {
let now = SystemTime::now();
let day = 24 * 60 * 60;
let images = vec![
("anvil-prepared:fresh".to_string(), at(now, 60)),
("anvil-prepared:ancient".to_string(), at(now, 40 * day)),
];
// Count is generous, but the 40-day-old image exceeds the 30-day age.
let doomed = select_for_prune(images, now, Some(Duration::from_secs(30 * day)), 100);
assert_eq!(doomed, vec!["anvil-prepared:ancient".to_string()]);
}
#[test]
fn prune_keeps_old_image_that_is_still_used() {
let now = SystemTime::now();
let day = 24 * 60 * 60;
// Built long ago but used a minute ago (marker mtime is recent).
let images = vec![("anvil-prepared:daily-driver".to_string(), at(now, 60))];
let doomed = select_for_prune(images, now, Some(Duration::from_secs(30 * day)), 10);
assert!(
doomed.is_empty(),
"a still-used image must survive regardless of build age"
);
}
#[test]
fn prune_no_age_limit_keeps_all_within_count() {
let now = SystemTime::now();
let images = vec![
("anvil-prepared:x".to_string(), at(now, 10)),
("anvil-prepared:y".to_string(), at(now, 10_000_000)),
];
assert!(select_for_prune(images, now, None, 10).is_empty());
}
}