deb: share the apt download cache across builds

With the chroot now per-run scratch, every build re-downloaded the
packages its dependency install needed — the very cost the chroot reuse
used to hide. Every chroot's /var/cache/apt/archives is now a bind
mount over a shared cache at /var/tmp/pkh/apt-cache/<series>-<arch>/
(the chroot's architecture, like the tarball cache), which one run's
installs fill and the next runs reuse: apt keeps downloaded packages
through a dropped conf (APT::Keep-Downloaded-Packages), and an
apt-get autoclean at the end of each run drops the debs the archive no
longer serves. pkh prune removes caches untouched for a week.

The run holds a host-side flock on the cache for its whole lifetime:
apt's own archives lock is acquired inside the chroot's pid namespace,
where a concurrent build's apt shows the holder as "process 0" — an
invisible process it refuses to wait for — so pkh serializes the
traffic itself; concurrent builds of the same target wait for each
other. Any bind-mount failure falls back to the chroot's own cache
with a warning (correct, just slower), and the temp-chroot teardown now
unmounts everything under the chroot before rm -rf, so the removal can
never reach through the cache bind mount and delete the shared cache
itself.
This commit is contained in:
2026-09-29 14:22:49 +02:00
parent dd2634abe3
commit 0e1e37b2e6
4 changed files with 363 additions and 47 deletions
+102 -7
View File
@@ -10,11 +10,17 @@
//! bind-mounted `/proc` and overlay filesystems, so they require careful
//! unmounting before they can be removed.
//! - **Build records** under `/var/tmp/pkh/sessions/<slug>/<record-id>/`
//! (see [`crate::deb::session`]): one immutable record per build; the
//! (see [`crate::deb::session`]): one record per build (metadata and
//! log for every run, the staged tree of `--incremental` ones); the
//! default policy removes the ones untouched for longer than the
//! record retention window plus the corrupt leftovers, `--all`
//! removes every record (only the last five per target are kept by
//! `pkh deb` itself).
//! removes every record (at most five staged trees per target are
//! kept by `pkh deb` itself).
//! - **Shared apt download caches** under `/var/tmp/pkh/apt-cache/`
//! (see [`crate::deb::ephemeral::apt_cache_dir`]): the default policy
//! removes the ones untouched for longer than the retention window,
//! `--all` removes them all; the `.debs` they hold are cheap to
//! re-download.
//! - **Cached chroot tarballs** (`~/.cache/pkh/*-buildd.tar.xz`) and their
//! **stale download lockfiles** (`~/.cache/pkh/*.lock` untouched for longer
//! than [`LOCK_STALE_AFTER`]; younger lockfiles may belong to a concurrent
@@ -103,6 +109,9 @@ enum Artifact {
Session(PathBuf),
/// A `pkh test` run directory (report artifacts).
TestRun(PathBuf),
/// A shared apt download cache of one target (see
/// [`crate::deb::ephemeral::apt_cache_dir`]).
AptCache(PathBuf),
/// An orphaned record lock file (`<record id>.lock` whose record
/// root is gone).
SessionLock(PathBuf),
@@ -118,6 +127,7 @@ impl Artifact {
| Artifact::LogFile(p)
| Artifact::Session(p)
| Artifact::TestRun(p)
| Artifact::AptCache(p)
| Artifact::SessionLock(p) => p,
}
}
@@ -131,6 +141,7 @@ impl Artifact {
Artifact::LogFile(_) => "build log",
Artifact::Session(_) => "build record",
Artifact::TestRun(_) => "pkh test run",
Artifact::AptCache(_) => "shared apt download cache",
Artifact::SessionLock(_) => "stale record lockfile",
}
}
@@ -231,6 +242,25 @@ fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec<Artifact> {
.collect()
}
/// Discover the shared apt download caches prune may remove under
/// `apt_cache_dir` (`/var/tmp/pkh/apt-cache`, one `<series>-<arch>`
/// subdirectory per target): with `--all`, every one; otherwise only
/// those untouched for longer than the retention window. A cache in use
/// by a live build is fresh by construction — its `.debs` are being
/// read and written.
fn discover_apt_cache(apt_cache_dir: &Path, all: bool) -> Vec<Artifact> {
let Ok(entries) = fs::read_dir(apt_cache_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| is_stale_path(path, all))
.map(Artifact::AptCache)
.collect()
}
/// Whether a record root is prunable under the given policy: everything
/// with `--all`, otherwise stale (untouched beyond the retention window)
/// or corrupt (no readable manifest) records. Live records record a fresh
@@ -582,19 +612,21 @@ pub fn prune_in(
cache_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
prune_in_roots(temp_dir, cache_dir, None, None, options)
prune_in_roots(temp_dir, cache_dir, None, None, None, options)
}
/// Execute the prune operation against explicit roots.
///
/// `sessions_dir` opts in to build-session pruning and `tests_dir` to
/// test-run pruning (the production [`prune()`] passes the real roots;
/// tests pass controlled directories or `None`).
/// `sessions_dir` opts in to build-record pruning, `tests_dir` to
/// test-run pruning and `apt_cache_dir` to shared apt-cache pruning (the
/// production [`prune()`] passes the real roots; tests pass controlled
/// directories or `None`).
pub fn prune_in_roots(
temp_dir: &Path,
cache_dir: Option<&Path>,
sessions_dir: Option<&Path>,
tests_dir: Option<&Path>,
apt_cache_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
let mut artifacts = discover_artifacts(temp_dir, cache_dir);
@@ -604,6 +636,9 @@ pub fn prune_in_roots(
if let Some(tests_dir) = tests_dir {
artifacts.extend(discover_test_runs(tests_dir, options.all));
}
if let Some(apt_cache_dir) = apt_cache_dir {
artifacts.extend(discover_apt_cache(apt_cache_dir, options.all));
}
let mut report = PruneReport {
dry_run: options.dry_run,
@@ -671,6 +706,7 @@ pub fn prune(options: PruneOptions) -> Result<PruneReport, Box<dyn std::error::E
cache.as_deref(),
Some(&crate::deb::session::sessions_root()),
Some(&crate::test::runs_root()),
Some(&PathBuf::from(crate::deb::ephemeral::APT_CACHE_ROOT)),
options,
)
}
@@ -872,6 +908,7 @@ none /tmp/other proc rw 0 0
Artifact::LogFile(_) => logs += 1,
Artifact::Session(_) => {}
Artifact::TestRun(_) => {}
Artifact::AptCache(_) => {}
Artifact::SessionLock(_) => {}
}
}
@@ -1307,6 +1344,7 @@ none /tmp/other proc rw 0 0
None,
Some(&sessions),
None,
None,
PruneOptions::default(),
)
.unwrap();
@@ -1322,6 +1360,7 @@ none /tmp/other proc rw 0 0
None,
Some(&sessions),
None,
None,
PruneOptions {
all: true,
..Default::default()
@@ -1356,6 +1395,7 @@ none /tmp/other proc rw 0 0
None,
None,
Some(&tests),
None,
PruneOptions::default(),
)
.unwrap();
@@ -1369,6 +1409,59 @@ none /tmp/other proc rw 0 0
None,
None,
Some(&tests),
None,
PruneOptions {
all: true,
..Default::default()
},
)
.unwrap();
assert!(report.removed.contains(&fresh));
assert!(!fresh.exists());
}
/// The shared apt download caches follow the retention window: a
/// cache untouched for over a week goes, one in recent use stays,
/// `--all` takes everything.
#[test]
fn prunes_stale_apt_caches_by_retention() {
let temp = tempdir().unwrap();
let apt_cache = temp.path().join("apt-cache");
let stale = apt_cache.join("noble-amd64");
let fresh = apt_cache.join("sid-riscv64");
for dir in [&stale, &fresh] {
fs::create_dir_all(dir.join("partial")).unwrap();
fs::write(dir.join("some-deb_1.0_amd64.deb"), "deb").unwrap();
}
// Directories open read-only (write-open is a file-only mode):
// futimens still applies through the read handle.
let dir = fs::File::open(&stale).unwrap();
dir.set_times(
std::fs::FileTimes::new()
.set_modified(SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60)),
)
.unwrap();
let report = prune_in_roots(
temp.path(),
None,
None,
None,
Some(&apt_cache),
PruneOptions::default(),
)
.unwrap();
assert!(report.removed.contains(&stale));
assert!(!stale.exists());
assert!(fresh.exists(), "a recently used cache is not pruned");
// --all removes every cache.
let report = prune_in_roots(
temp.path(),
None,
None,
None,
Some(&apt_cache),
PruneOptions {
all: true,
..Default::default()
@@ -1415,6 +1508,7 @@ none /tmp/other proc rw 0 0
None,
Some(&sessions),
None,
None,
PruneOptions::default(),
)
.unwrap();
@@ -1432,6 +1526,7 @@ none /tmp/other proc rw 0 0
None,
Some(&sessions),
None,
None,
PruneOptions {
all: true,
..Default::default()