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:
+139
-9
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user