deb: rework build sessions into immutable per-build records
The single mutable session per identity loses the last-good state: a failed run after a good one adopts the session in place and leaves only the failed state behind, so "continue from the last success" was impossible. Each build now records an immutable record under /var/tmp/pkh/sessions/<slug>/<id>/ (chroot, tree-upper, journal, manifest), and --incremental forks a NEW record — a full copy of the base's chroot and, at the same changelog version, its staged artifacts — under the base's flock, never mutating the base itself. Selection picks the newest successful record of the identity among the last five usable, falling back to the newest when none succeeded, and --incremental <id> pins an exact record (failed ones allowed: their environment may be fine). The lifecycle is strict keep-iff-incremental: plain runs build in a throwaway record discarded on success, failure and interrupt alike (the SIGINT cleanup hook follows the same policy), while --incremental records are always kept. Each identity keeps its newest five records; pkh prune's 7-day sweep and orphaned-lock cleanup follow the nested layout. --resume and --keep are removed (no aliases): their adoption-in-place and keep-on-success semantics are exactly what the record model replaces. pkh deb list shows id, outcome, tree version, fork lineage and age. README rewritten around the record workflow.
This commit is contained in:
+116
-56
@@ -9,11 +9,12 @@
|
||||
//! 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.
|
||||
//! - **Build records** under `/var/tmp/pkh/sessions/<slug>/<record-id>/`
|
||||
//! (see [`crate::deb::session`]): one immutable record per build; 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).
|
||||
//! - **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
|
||||
@@ -97,12 +98,12 @@ enum Artifact {
|
||||
Tarball(PathBuf),
|
||||
/// A build log file under `<cache>/logs`.
|
||||
LogFile(PathBuf),
|
||||
/// A resumable build session root (chroot + journal).
|
||||
/// A build record root (chroot + journal), one per build.
|
||||
Session(PathBuf),
|
||||
/// A `pkh test` run directory (report artifacts).
|
||||
TestRun(PathBuf),
|
||||
/// An orphaned session lock file (`<slug>.lock` whose session root is
|
||||
/// gone).
|
||||
/// An orphaned record lock file (`<record id>.lock` whose record
|
||||
/// root is gone).
|
||||
SessionLock(PathBuf),
|
||||
}
|
||||
|
||||
@@ -127,22 +128,26 @@ impl Artifact {
|
||||
Artifact::LockFile(_) => "stale lockfile",
|
||||
Artifact::Tarball(_) => "cached chroot tarball",
|
||||
Artifact::LogFile(_) => "build log",
|
||||
Artifact::Session(_) => "resumable build session",
|
||||
Artifact::Session(_) => "build record",
|
||||
Artifact::TestRun(_) => "pkh test run",
|
||||
Artifact::SessionLock(_) => "stale session lockfile",
|
||||
Artifact::SessionLock(_) => "stale record lockfile",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Discover the build records prune may remove under `sessions_dir` (the
|
||||
/// record layout of [`crate::deb::session`]: one `<record-id>` directory
|
||||
/// — with its `<record-id>.lock` beside it — per slug directory): with
|
||||
/// `--all`, every record; otherwise only those untouched for longer than
|
||||
/// the retention window plus the corrupt leftovers.
|
||||
///
|
||||
/// Orphaned lock files (`<slug>.lock` whose session root is gone — left
|
||||
/// behind by failed/interrupted runs) are collected too: by default only
|
||||
/// the ones no live build holds (probed with a non-blocking flock), with
|
||||
/// `--all` every one of them.
|
||||
/// Orphaned lock files (`<record id>.lock` whose record root is gone —
|
||||
/// left behind by interrupted or discarded runs) are collected too: by
|
||||
/// default only the ones no live build holds (probed with a non-blocking
|
||||
/// flock), with `--all` every one of them. The pre-record single-session
|
||||
/// layout kept its lock at the sessions root and its manifest directly
|
||||
/// inside the slug directory; both are collected as the leftovers they
|
||||
/// became.
|
||||
fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
|
||||
let Ok(entries) = fs::read_dir(sessions_dir) else {
|
||||
return Vec::new();
|
||||
@@ -151,21 +156,64 @@ fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// The old layout's `<slug>.lock` at the root: orphaned once no
|
||||
// slug directory of that name exists.
|
||||
if let Some(slug) = name.strip_suffix(".lock") {
|
||||
if !path.is_file() || sessions_dir.join(slug).is_dir() {
|
||||
continue;
|
||||
if path.is_file() && !sessions_dir.join(slug).is_dir() {
|
||||
collect_lock(&path, all, &mut artifacts);
|
||||
}
|
||||
let held = crate::deb::session::lock_is_held(&path).unwrap_or(true);
|
||||
if all || !held {
|
||||
artifacts.push(Artifact::SessionLock(path));
|
||||
continue;
|
||||
}
|
||||
let Some(slug_dir) = path.is_dir().then_some(path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(records) = fs::read_dir(&slug_dir) else {
|
||||
continue;
|
||||
};
|
||||
for record in records.flatten() {
|
||||
let path = record.path();
|
||||
let name = record.file_name().to_string_lossy().into_owned();
|
||||
if let Some(record_id) = name.strip_suffix(".lock") {
|
||||
if path.is_file() && !slug_dir.join(record_id).is_dir() {
|
||||
collect_lock(&path, all, &mut artifacts);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
if is_prunable_session(&path, all) {
|
||||
artifacts.push(Artifact::Session(path));
|
||||
}
|
||||
} else if is_stale_path(&path, all) {
|
||||
// A record root only ever holds directories; a plain
|
||||
// file inside a slug directory is a leftover of the old
|
||||
// flat layout (its session.json / host-tree snapshot).
|
||||
artifacts.push(Artifact::Session(path));
|
||||
}
|
||||
} else if path.is_dir() && is_prunable_session(&path, all) {
|
||||
artifacts.push(Artifact::Session(path));
|
||||
}
|
||||
}
|
||||
artifacts
|
||||
}
|
||||
|
||||
/// Collect a lock file as prunable when no live build holds it (or `--all`
|
||||
/// was requested).
|
||||
fn collect_lock(path: &Path, all: bool, artifacts: &mut Vec<Artifact>) {
|
||||
let held = crate::deb::session::lock_is_held(path).unwrap_or(true);
|
||||
if all || !held {
|
||||
artifacts.push(Artifact::SessionLock(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path is untouched for longer than the record retention
|
||||
/// window (mtime-based; the fallback for leftovers with no manifest).
|
||||
fn is_stale_path(path: &Path, all: bool) -> bool {
|
||||
all || fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.is_ok_and(|mtime| {
|
||||
chrono::Utc::now()
|
||||
.signed_duration_since(chrono::DateTime::<chrono::Utc>::from(mtime))
|
||||
.to_std()
|
||||
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION)
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover the `pkh test` run directories prune may remove under
|
||||
/// `tests_dir`: with `--all`, every run; otherwise only those untouched
|
||||
/// for longer than the sessions retention window.
|
||||
@@ -177,24 +225,17 @@ fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec<Artifact> {
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.filter(|path| {
|
||||
all || fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.is_ok_and(|mtime| {
|
||||
chrono::Utc::now()
|
||||
.signed_duration_since(chrono::DateTime::<chrono::Utc>::from(mtime))
|
||||
.to_std()
|
||||
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION)
|
||||
})
|
||||
})
|
||||
.filter(|path| is_stale_path(path, all))
|
||||
.map(Artifact::TestRun)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a session root is prunable under the given policy: everything
|
||||
/// 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) sessions. Live sessions record a
|
||||
/// fresh `last_used` on every journal write and are never stale.
|
||||
/// or corrupt (no readable manifest) records. Live records record a fresh
|
||||
/// `last_used` when they are created and are never stale; a record whose
|
||||
/// manifest is missing is mid-creation rather than corrupt while its
|
||||
/// lock is held, and is left alone.
|
||||
fn is_prunable_session(path: &Path, all: bool) -> bool {
|
||||
if all {
|
||||
return true;
|
||||
@@ -213,8 +254,18 @@ fn is_prunable_session(path: &Path, all: bool) -> bool {
|
||||
.signed_duration_since(then)
|
||||
.to_std()
|
||||
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION),
|
||||
// No readable manifest: an inert leftover.
|
||||
None => true,
|
||||
// No readable manifest: a leftover — unless a live build holds
|
||||
// the record's lock (mid-creation or mid-fork); an unreadable
|
||||
// probe also errs on keeping it.
|
||||
None => {
|
||||
let lock = path.with_file_name(format!(
|
||||
"{}.lock",
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
matches!(crate::deb::session::lock_is_held(&lock), Ok(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,7 +1272,8 @@ none /tmp/other proc rw 0 0
|
||||
assert!(report.is_empty());
|
||||
}
|
||||
|
||||
/// Write a minimal session manifest with the given `last_used`.
|
||||
/// Write a minimal record manifest with the given `last_used` under
|
||||
/// `sessions/<slug>/<record>/`.
|
||||
fn write_session_manifest(session_dir: &Path, last_used: &str) {
|
||||
fs::create_dir_all(session_dir).unwrap();
|
||||
fs::write(
|
||||
@@ -1231,15 +1283,16 @@ none /tmp/other proc rw 0 0
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Session pruning: stale and corrupt sessions go by default, live ones
|
||||
/// Record pruning: stale and corrupt records 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");
|
||||
let slug = sessions.join("pkh-noble-amd64-1c0ffee0");
|
||||
let stale = slug.join("20200101T000000");
|
||||
let fresh = slug.join("20260929T000000");
|
||||
let corrupt = slug.join("20260929T000001");
|
||||
write_session_manifest(&stale, "2020-01-01T00:00:00+00:00");
|
||||
write_session_manifest(
|
||||
&fresh,
|
||||
@@ -1258,11 +1311,11 @@ none /tmp/other proc rw 0 0
|
||||
.unwrap();
|
||||
assert!(report.removed.contains(&stale));
|
||||
assert!(report.removed.contains(&corrupt));
|
||||
assert!(fresh.exists(), "a live session is not pruned by default");
|
||||
assert!(fresh.exists(), "a live record is not pruned by default");
|
||||
assert!(!stale.exists());
|
||||
assert!(!corrupt.exists());
|
||||
|
||||
// --all removes everything, including the live session.
|
||||
// --all removes everything, including the live record.
|
||||
let report = prune_in_roots(
|
||||
temp.path(),
|
||||
None,
|
||||
@@ -1325,22 +1378,27 @@ none /tmp/other proc rw 0 0
|
||||
assert!(!fresh.exists());
|
||||
}
|
||||
|
||||
/// Orphaned session lock files are pruned (unless held by a live
|
||||
/// build); locks whose session root still exists are never touched.
|
||||
/// Orphaned record lock files are pruned (unless held by a live
|
||||
/// build); locks whose record root still exists are never touched.
|
||||
/// The pre-record layout's locks at the sessions root follow the same
|
||||
/// rules.
|
||||
#[test]
|
||||
fn test_session_lock_pruning() {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let temp = tempdir().unwrap();
|
||||
let sessions = temp.path().join("sessions");
|
||||
let orphan_free = sessions.join("free.lock");
|
||||
let orphan_held = sessions.join("held.lock");
|
||||
let rooted = sessions.join("rooted.lock");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let slug = sessions.join("pkh-noble-amd64-1c0ffee0");
|
||||
let orphan_free = slug.join("20260929T000000.lock");
|
||||
let orphan_held = slug.join("20260929T000001.lock");
|
||||
let rooted = slug.join("20260929T000002.lock");
|
||||
let legacy = sessions.join("legacy-slug.lock");
|
||||
fs::create_dir_all(&slug).unwrap();
|
||||
fs::write(&orphan_free, b"").unwrap();
|
||||
fs::write(&orphan_held, b"").unwrap();
|
||||
fs::write(&rooted, b"").unwrap();
|
||||
fs::create_dir_all(sessions.join("rooted")).unwrap();
|
||||
fs::create_dir_all(slug.join("20260929T000002")).unwrap();
|
||||
fs::write(&legacy, b"").unwrap();
|
||||
|
||||
// Hold one orphaned lock, as a live build would.
|
||||
let holder = fs::OpenOptions::new()
|
||||
@@ -1361,10 +1419,12 @@ none /tmp/other proc rw 0 0
|
||||
.unwrap();
|
||||
assert!(report.removed.contains(&orphan_free));
|
||||
assert!(!orphan_free.exists());
|
||||
assert!(report.removed.contains(&legacy));
|
||||
assert!(!legacy.exists());
|
||||
assert!(orphan_held.exists(), "a held lock belongs to a live build");
|
||||
assert!(rooted.exists(), "a lock guarding an existing root stays");
|
||||
assert!(rooted.exists(), "a lock guarding an existing record stays");
|
||||
|
||||
// --all takes the held orphan too (the root it guarded is gone).
|
||||
// --all takes the held orphan too (the record it guarded is gone).
|
||||
drop(holder);
|
||||
let report = prune_in_roots(
|
||||
temp.path(),
|
||||
|
||||
Reference in New Issue
Block a user