prune: garbage-collect resumable build sessions

Sessions accumulate under /var/tmp/pkh/sessions for every failed,
interrupted or --keep build and can reach tens of gigabytes for
kernel-sized chroots: prune is their primary GC. By default remove
the sessions untouched for longer than the retention window (7
days) plus the corrupt leftovers; --all removes everything. Session
roots hold the same mounts as residual chroots (/proc bind mount,
overlays), so they are unmounted before removal.

prune_in() (the testable core) deliberately stays session-free:
the test suite runs prune tests and e2e builds concurrently, and
scanning the real sessions root from the tests deleted live
sessions mid-build. Session pruning is an explicit opt-in of
prune_in_roots(), used by the production prune().
This commit is contained in:
2026-09-26 11:51:46 +02:00
parent 8ff423ffb8
commit aaa6723d2f
+139 -9
View File
@@ -9,6 +9,11 @@
//! may contain root-owned device nodes (created via `mknod`), a
//! bind-mounted `/proc` and overlay filesystems, so they require careful
//! unmounting before they can be removed.
//! - **Resumable build sessions** under `/var/tmp/pkh/sessions` (see
//! [`crate::deb::session`]): a kept session is a resumable build; the
//! default policy removes the ones untouched for longer than the session
//! retention window plus the corrupt leftovers, `--all` removes every
//! session.
//! - **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
@@ -92,6 +97,8 @@ enum Artifact {
Tarball(PathBuf),
/// A build log file under `<cache>/logs`.
LogFile(PathBuf),
/// A resumable build session root (chroot + journal).
Session(PathBuf),
}
impl Artifact {
@@ -101,7 +108,8 @@ impl Artifact {
| Artifact::KeyringDir(p)
| Artifact::LockFile(p)
| Artifact::Tarball(p)
| Artifact::LogFile(p) => p,
| Artifact::LogFile(p)
| Artifact::Session(p) => p,
}
}
@@ -112,10 +120,55 @@ impl Artifact {
Artifact::LockFile(_) => "stale lockfile",
Artifact::Tarball(_) => "cached chroot tarball",
Artifact::LogFile(_) => "build log",
Artifact::Session(_) => "resumable build session",
}
}
}
/// Discover the build sessions that prune may remove under `sessions_dir`:
/// with `--all`, every session; otherwise only those untouched for longer
/// than the retention window ([`crate::deb::session::SESSION_RETENTION`])
/// and the ones whose manifest is missing or unreadable (inert leftovers).
fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
let Ok(entries) = fs::read_dir(sessions_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| is_prunable_session(path, all))
.map(Artifact::Session)
.collect()
}
/// Whether a session root is prunable under the given policy: everything
/// with `--all`, otherwise stale (untouched beyond the retention window)
/// or corrupt (no readable manifest) sessions. Live sessions record a
/// fresh `last_used` on every journal write and are never stale.
fn is_prunable_session(path: &Path, all: bool) -> bool {
if all {
return true;
}
let last_used = fs::read_to_string(path.join("session.json"))
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.and_then(|value| {
value
.get("last_used")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
});
match last_used {
Some(then) => chrono::Utc::now()
.signed_duration_since(then)
.to_std()
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION),
// No readable manifest: an inert leftover.
None => true,
}
}
/// Determine the pkh cache directory (e.g. `~/.cache/pkh`), if project dirs
/// can be resolved on this platform.
pub fn cache_dir() -> Option<PathBuf> {
@@ -413,19 +466,39 @@ fn unmount(mountpoint: &str) -> std::io::Result<()> {
}
}
/// Execute the prune operation against the given roots, removing residual pkh
/// artifacts.
/// Execute the prune operation against the given temp and cache roots,
/// removing residual pkh artifacts.
///
/// This is the testable core of [`prune()`]: callers pass in the temp
/// directory and optional cache directory to scan. See the
/// [module documentation](self) for what is removed. Returns a [`PruneReport`]
/// describing what was removed (or, in dry-run mode, what would be removed).
/// directory and optional cache directory to scan. Build sessions are
/// deliberately out of scope here — tests scan controlled roots, and a
/// concurrent test suite must never touch the live sessions under the
/// real sessions root. See the [module documentation](self) for what is
/// removed. Returns a [`PruneReport`] describing what was removed (or, in
/// dry-run mode, what would be removed).
pub fn prune_in(
temp_dir: &Path,
cache_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
let artifacts = discover_artifacts(temp_dir, cache_dir);
prune_in_roots(temp_dir, cache_dir, None, options)
}
/// Execute the prune operation against explicit roots.
///
/// `sessions_dir` opts in to build-session pruning (the production
/// [`prune()`] passes the real sessions root; tests pass a controlled
/// directory or `None`).
pub fn prune_in_roots(
temp_dir: &Path,
cache_dir: Option<&Path>,
sessions_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
let mut artifacts = discover_artifacts(temp_dir, cache_dir);
if let Some(sessions_dir) = sessions_dir {
artifacts.extend(discover_sessions(sessions_dir, options.all));
}
let mut report = PruneReport {
dry_run: options.dry_run,
@@ -459,7 +532,8 @@ pub fn prune_in(
// Residual build directories may hold bind-mounted /proc and overlay
// filesystems that must be unmounted before the directory can be
// removed, otherwise `rm -rf` will fail or leave dangling mounts.
if let Artifact::TempDir(_) = artifact {
// Session roots hold the same kind of mounts inside their chroot.
if let Artifact::TempDir(_) | Artifact::Session(_) = artifact {
let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default();
for mp in mounts_under_from_content(&mounts_content, path) {
log::debug!("Unmounting {} under {}", mp, path.display());
@@ -487,7 +561,12 @@ pub fn prune_in(
pub fn prune(options: PruneOptions) -> Result<PruneReport, Box<dyn std::error::Error>> {
let temp_dir = std::env::temp_dir();
let cache = cache_dir();
prune_in(&temp_dir, cache.as_deref(), options)
prune_in_roots(
&temp_dir,
cache.as_deref(),
Some(&crate::deb::session::sessions_root()),
options,
)
}
#[cfg(test)]
@@ -685,6 +764,7 @@ none /tmp/other proc rw 0 0
Artifact::LockFile(_) => locks += 1,
Artifact::Tarball(_) => tarballs += 1,
Artifact::LogFile(_) => logs += 1,
Artifact::Session(_) => {}
}
}
assert_eq!(temp_dirs, 2);
@@ -1084,4 +1164,54 @@ none /tmp/other proc rw 0 0
let report = prune_in(temp.path(), Some(cache.path()), PruneOptions::default()).unwrap();
assert!(report.is_empty());
}
/// Write a minimal session manifest with the given `last_used`.
fn write_session_manifest(session_dir: &Path, last_used: &str) {
fs::create_dir_all(session_dir).unwrap();
fs::write(
session_dir.join("session.json"),
format!(r#"{{ "last_used": "{last_used}" }}"#),
)
.unwrap();
}
/// Session pruning: stale and corrupt sessions go by default, live ones
/// stay; `--all` takes everything.
#[test]
fn test_session_pruning_retention() {
let temp = tempdir().unwrap();
let sessions = temp.path().join("sessions");
let stale = sessions.join("stale");
let fresh = sessions.join("fresh");
let corrupt = sessions.join("corrupt");
write_session_manifest(&stale, "2020-01-01T00:00:00+00:00");
write_session_manifest(
&fresh,
&(chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339(),
);
fs::create_dir_all(&corrupt).unwrap();
fs::write(corrupt.join("session.json"), "not json").unwrap();
let report =
prune_in_roots(temp.path(), None, Some(&sessions), PruneOptions::default()).unwrap();
assert!(report.removed.contains(&stale));
assert!(report.removed.contains(&corrupt));
assert!(fresh.exists(), "a live session is not pruned by default");
assert!(!stale.exists());
assert!(!corrupt.exists());
// --all removes everything, including the live session.
let report = prune_in_roots(
temp.path(),
None,
Some(&sessions),
PruneOptions {
all: true,
..Default::default()
},
)
.unwrap();
assert!(report.removed.contains(&fresh));
assert!(!fresh.exists());
}
}