//! Pruning a build cache before it is kept (oxygenflow#72, spec ยง5.4). //! //! A kept disk starts the next run, so without pruning it carries every //! build's files: each crate's artifacts under every hash a lockfile or a //! feature set ever gave it. Before a passing job's cache disk goes back //! into the pool, the files this run did not use are removed, oldest //! first, until what is on the disk is at most a share of its size (the //! cap). The microVM's guest init prunes its disks before it powers off, //! since only it can see inside them; the runner prunes the syscall //! tier's directories itself. Both use this module. //! //! What the run used is told by the files' times against when the job //! started: written (mtime), changed (ctime) or read (atime). Reads are //! seen only where the file system records them: the guest mounts a cache //! disk `strictatime`; on the runner, where a directory sits on whatever //! file system the runner has, each file's atime is set to the epoch //! before the job ([`forget_reads`]), so even `relatime` records its //! first read. A file system mounted `noatime` records none, and then //! nothing is pruned. //! //! A build tool does not read everything it relies on. Cargo, finding a //! crate fresh, reads its fingerprint and only looks at its rlib and //! binaries, so their times say nothing. Every name cargo gives a unit's //! files carries the unit's hash (`.fingerprint/foo-/`, //! `deps/libfoo-.rlib`, `build/foo-/`), so a file stays when //! a file this run used carries one of the hashes in its path: a fresh //! crate's fingerprint keeps its artifacts. A file with another link //! that stays, or under a path the job's outputs name, stays too. What //! goes is what this run's build did not touch, which a later build needs //! only if an older lockfile comes back, and then cargo rebuilds it. use serde::{Deserialize, Serialize}; #[cfg(unix)] use std::collections::{HashMap, HashSet}; #[cfg(unix)] use std::os::unix::fs::MetadataExt; use std::path::Path; #[cfg(unix)] use std::path::PathBuf; use std::time::{Duration, SystemTime}; /// The share of a cache disk a kept cache may fill when no runner says /// otherwise: room for this run's build and as much again. pub const DEFAULT_PERCENT: u8 = 60; /// What pruning a cache found and did, in bytes of blocks on its disk. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct Pruned { /// The disk's size, and the share of it the cache may fill. pub size: u64, pub percent: u8, /// What was on the disk before pruning, and after. pub before: u64, pub after: u64, /// What went: bytes and files. pub freed: u64, pub files: u64, /// Why nothing could be pruned, when so (no access times, or an /// error): only the size then decides. #[serde(default, skip_serializing_if = "Option::is_none")] pub unpruned: Option, } impl Pruned { /// The most the cache may keep: `percent` of `size`. pub fn cap(&self) -> u64 { cap(self.size, self.percent) } /// Whether what is left may be kept. pub fn fits(&self) -> bool { self.after <= self.cap() } } fn cap(size: u64, percent: u8) -> u64 { (size as u128 * percent.min(100) as u128 / 100) as u64 } /// Now, as a job's start to compare file times against. File systems /// stamp times from a clock that may lag this one by a tick, so the /// moments either side of it are left to pass: nothing done before the /// call (such as [`forget_reads`]) can look as though it came after. pub fn start_clock() -> SystemTime { std::thread::sleep(Duration::from_millis(20)); let t = SystemTime::now(); std::thread::sleep(Duration::from_millis(20)); t } #[cfg(unix)] /// Whether the file system holding `path` records reads (not `noatime`). pub fn records_reads(path: &Path) -> bool { let Ok(c) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { return false }; let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { return false; } st.f_flag & libc::ST_NOATIME == 0 } #[cfg(unix)] /// The blocks in use on the file system mounted at `path`, and its size /// (as `df` counts them). pub fn usage(path: &Path) -> std::io::Result<(u64, u64)> { let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?; let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { return Err(std::io::Error::last_os_error()); } let unit = st.f_frsize as u64; Ok(((st.f_blocks as u64).saturating_sub(st.f_bfree as u64) * unit, st.f_blocks as u64 * unit)) } #[cfg(unix)] /// Set every regular file's atime under `root` to the epoch, its mtime /// kept, so the job's first read of each is recorded even on `relatime` /// (which records a read only of a file read no later than it changed). /// Links are not followed. The count of files. pub fn forget_reads(root: &Path) -> std::io::Result { let mut n = 0; let mut err = None; walk(root, &mut |p, md| { if !md.file_type().is_file() { return; } let Ok(c) = std::ffi::CString::new(p.as_os_str().as_encoded_bytes()) else { return }; let times = [libc::timespec { tv_sec: 0, tv_nsec: 0 }, libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_OMIT }]; if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) } == 0 { n += 1; } else if err.is_none() { err = Some(std::io::Error::last_os_error()); } })?; match err { Some(e) => Err(e), None => Ok(n), } } #[cfg(unix)] /// Each entry under `root` (not `root` itself) with its metadata, parents /// before children; links are not followed, nor other file systems /// entered. fn walk(root: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) -> std::io::Result<()> { let dev = std::fs::symlink_metadata(root)?.dev(); let mut dirs = vec![root.to_path_buf()]; while let Some(d) = dirs.pop() { let Ok(entries) = std::fs::read_dir(&d) else { continue }; for e in entries.flatten() { let p = e.path(); let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; if md.dev() != dev { continue; } f(&p, &md); if md.is_dir() { dirs.push(p); } } } Ok(()) } #[cfg(unix)] /// The unit hashes in `rel`: a 16-digit lowercase hex word after a `-` /// in any of its names, as cargo names a unit's files. fn hashes(rel: &Path) -> impl Iterator + '_ { rel.components().filter_map(|c| c.as_os_str().to_str()).flat_map(|name| { name.split('-').skip(1).filter_map(|w| { let w = w.split('.').next().unwrap_or(""); (w.len() == 16 && w.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))).then(|| w.to_string()) }) }) } #[cfg(unix)] fn nanos(s: i64, ns: i64) -> i128 { s as i128 * 1_000_000_000 + ns as i128 } #[cfg(unix)] /// One file, however many names it has. struct File { paths: Vec, links: u64, bytes: u64, /// When it was last used as far as can be told: the later of its /// atime and mtime. last: i128, used: bool, } #[cfg(unix)] /// Prune the cache at `root`, a disk of `size` bytes with `before` in /// use, down to `percent` of its size, removing only regular files this /// run did not use (see the module's notes), those used longest ago /// first. `started` is when the job started ([`start_clock`]); `keep` are /// paths relative to `root` (the job's outputs) that stay whatever their /// times. Under the cap nothing is touched. `after` is `before` less what /// was freed. pub fn prune(root: &Path, started: SystemTime, keep: &[String], size: u64, before: u64, percent: u8) -> std::io::Result { let mut out = Pruned { size, percent, before, after: before, ..Pruned::default() }; if before <= out.cap() { return Ok(out); } let since = started.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos() as i128).unwrap_or(0); let keep: Vec = keep.iter().map(|k| PathBuf::from(k.trim_start_matches('/'))).filter(|k| !k.as_os_str().is_empty()).collect(); let mut files: HashMap<(u64, u64), File> = HashMap::new(); walk(root, &mut |p, md| { if !md.file_type().is_file() { return; } let rel = p.strip_prefix(root).unwrap_or(p); let used = [nanos(md.atime(), md.atime_nsec()), nanos(md.mtime(), md.mtime_nsec()), nanos(md.ctime(), md.ctime_nsec())] .into_iter() .any(|t| t >= since) || keep.iter().any(|k| rel.starts_with(k)); let f = files.entry((md.dev(), md.ino())).or_insert_with(|| File { paths: Vec::new(), links: md.nlink(), bytes: md.blocks() * 512, last: nanos(md.atime(), md.atime_nsec()).max(nanos(md.mtime(), md.mtime_nsec())), used: false, }); f.paths.push(p.to_path_buf()); f.used |= used; })?; // A hash any used file carries keeps every file carrying it. let live: HashSet = files.values().filter(|f| f.used).flat_map(|f| f.paths.iter().flat_map(|p| hashes(p.strip_prefix(root).unwrap_or(p)))).collect(); let mut gone: Vec = files .into_values() .filter(|f| { !f.used // A link from outside the cache: removing it frees nothing. && f.links == f.paths.len() as u64 && !f.paths.iter().any(|p| hashes(p.strip_prefix(root).unwrap_or(p)).any(|h| live.contains(&h))) }) .collect(); gone.sort_by_key(|f| f.last); let mut emptied: HashSet = HashSet::new(); for f in gone { if out.after <= out.cap() { break; } let mut removed = true; for p in &f.paths { if std::fs::remove_file(p).is_err() { removed = false; } else if let Some(parent) = p.parent() { emptied.insert(parent.to_path_buf()); } } if removed { out.after = out.after.saturating_sub(f.bytes); out.freed += f.bytes; out.files += 1; } } // Directories pruning emptied go too, deepest first; never `root`. let mut dirs: Vec = emptied.into_iter().collect(); dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count())); for d in dirs { let mut d = d.as_path(); while d != root && d.starts_with(root) && std::fs::remove_dir(d).is_ok() { match d.parent() { Some(p) => d = p, None => break, } } } Ok(out) } // Elsewhere (a Windows runner, which has no microVM tier and so no cache // disks to prune) nothing records reads, so nothing is pruned. #[cfg(not(unix))] pub fn records_reads(_path: &Path) -> bool { false } #[cfg(not(unix))] pub fn usage(_path: &Path) -> std::io::Result<(u64, u64)> { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux")) } #[cfg(not(unix))] pub fn forget_reads(_root: &Path) -> std::io::Result { Ok(0) } #[cfg(not(unix))] pub fn prune(_root: &Path, _started: SystemTime, _keep: &[String], _size: u64, _before: u64, _percent: u8) -> std::io::Result { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux")) } #[cfg(all(test, unix))] mod tests { use super::*; /// Times around a job that started at `T`, ahead of the clock: a file /// whose times are set is changed (ctime) now, before it. const T: u64 = 4_000_000_000; fn at(secs: u64) -> SystemTime { SystemTime::UNIX_EPOCH + Duration::from_secs(secs) } /// A file of `kib` KiB at `rel`, last read and written at `secs`. fn file(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); let bytes: Vec = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); std::fs::write(&p, bytes).unwrap(); let f = std::fs::File::options().write(true).open(&p).unwrap(); f.set_times(std::fs::FileTimes::new().set_accessed(at(secs)).set_modified(at(secs))).unwrap(); p } fn bytes(p: &Path) -> u64 { std::fs::metadata(p).unwrap().blocks() * 512 } #[test] fn hashes_are_cargos_unit_hashes() { let h = |p: &str| hashes(Path::new(p)).collect::>(); assert_eq!(h("debug/deps/libdep-8f2b19826c505967.rlib"), ["8f2b19826c505967"]); assert_eq!(h("debug/.fingerprint/dep-8f2b19826c505967/lib-dep"), ["8f2b19826c505967"]); assert_eq!(h("debug/build/my-crate-0123456789abcdef/out/x.o"), ["0123456789abcdef"]); assert!(h("debug/incremental/app-2dadnjnzlrswc/s-hndl5uwdpw-1uaho4m.lock").is_empty()); assert!(h("debug/app").is_empty() && h("debug/deps/x-0123456789ABCDEF").is_empty() && h("x-0123456789abcde").is_empty()); } #[test] fn under_the_cap_nothing_is_touched() { let dir = tempfile::tempdir().unwrap(); let old = file(dir.path(), "debug/deps/libold-1111111111111111.rlib", 64, 1_000); let p = prune(dir.path(), at(T), &[], 1 << 30, 64 << 10, 60).unwrap(); assert!(old.exists() && p.fits() && p.freed == 0 && p.after == p.before, "{p:?}"); } /// A cargo target as a run leaves it: this lockfile's units used this /// run (one rebuilt, one fresh: only its fingerprint read), an older /// lockfile's not, and an output the run did not touch. #[test] fn what_this_run_did_not_use_goes_oldest_first_and_its_units_stay_whole() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let app = file(root, "debug/deps/app-aaaaaaaaaaaaaaaa", 256, T + 100); let app_fp = file(root, "debug/.fingerprint/app-aaaaaaaaaaaaaaaa/bin-app", 1, T + 100); let dep_rlib = file(root, "debug/deps/libdep-bbbbbbbbbbbbbbbb.rlib", 256, T - 5_000); let dep_fp = file(root, "debug/.fingerprint/dep-bbbbbbbbbbbbbbbb/lib-dep", 1, T - 5_000); std::fs::File::open(&dep_fp).unwrap().set_times(std::fs::FileTimes::new().set_accessed(at(T + 50))).unwrap(); let script = file(root, "debug/build/dep-bbbbbbbbbbbbbbbb/out/gen.rs", 64, T - 5_000); // Uplifted: another name of the rebuilt binary. std::fs::hard_link(&app, root.join("debug/app")).unwrap(); // An older lockfile's units, untouched, the oldest first to go. let oldest = file(root, "debug/deps/libdep-cccccccccccccccc.rlib", 256, T - 9_000); let oldest_fp = file(root, "debug/.fingerprint/dep-cccccccccccccccc/lib-dep", 1, T - 8_999); let older = file(root, "debug/deps/libdep-dddddddddddddddd.rlib", 256, T - 8_000); // A declared output, older still, that the run did not touch. let output = file(root, "release/tool", 128, T - 10_000); let all = [&app, &app_fp, &dep_rlib, &dep_fp, &script, &oldest, &oldest_fp, &older, &output]; let before: u64 = all.iter().map(|p| bytes(p)).sum(); // Room for all but the oldest unit. let want = before - bytes(&oldest) - bytes(&oldest_fp); let size = (want * 100).div_ceil(60); let p = prune(root, at(T), &["/release/tool".into()], size, before, 60).unwrap(); assert!(p.fits(), "{p:?}"); assert!( !oldest.exists() && !root.join("debug/.fingerprint/dep-cccccccccccccccc").exists(), "the oldest unit went, its emptied directory too" ); assert!(older.exists(), "and pruning stopped under the cap: {p:?}"); for kept in [&app, &app_fp, &dep_rlib, &dep_fp, &script, &output, &root.join("debug/app")] { assert!(kept.exists(), "{} stays", kept.display()); } assert_eq!((p.files, p.freed, p.after), (2, p.before - want, want)); // With no room at all: still only what the run did not use goes. let p = prune(root, at(T), &["release/tool".into()], 1, p.after, 60).unwrap(); assert!(!older.exists() && !p.fits() && p.files == 1, "{p:?}"); for kept in [&app, &dep_rlib, &dep_fp, &script, &output] { assert!(kept.exists(), "{} stays", kept.display()); } assert!(root.join("debug/deps").is_dir(), "a directory still holding files stays"); } #[test] fn reads_are_forgotten_so_the_next_is_seen() { let dir = tempfile::tempdir().unwrap(); let p = file(dir.path(), "a/b", 1, 5_000); std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap(); assert_eq!(forget_reads(dir.path()).unwrap(), 1); let md = std::fs::metadata(&p).unwrap(); assert_eq!((md.atime(), md.mtime()), (0, 5_000), "the mtime is kept"); assert!(std::fs::symlink_metadata(dir.path().join("link")).unwrap().atime() > 0, "a link is not followed"); } }