//! What workflows keep from run to run: for each path a workflow names //! under `cache:`, a base, the directory its runs start from, which every //! run sees through an overlay of its own, so runs of one workflow go side //! by side rather than taking turns at it. //! //! A run's overlay has the base as its lower layer, read only, and a //! directory of the run's own as its upper one, which takes all it writes. //! Only a run of a push to the default branch that passes keeps what it //! wrote: its upper layer goes into a new base, which the runs started //! after it start from. Every other run's upper layer, a pull's among //! them, goes when the run ends, so what a pull writes never reaches the //! base, and a pull starts from what the default branch last left. //! //! A base is never changed while a run may read it: a new one is made //! beside it, its files hard links to the old one's where the upper layer //! left them as they were, and the old one goes once the last run reading //! it ends. The overlays are mounted with `userxattr`, so what says a //! directory in an upper layer hides the one below it is an extended //! attribute the runner can read without privileges. //! //! A trusted pull's run that passes, which a push to the default branch //! at the same tree may take as its own rather than run again, keeps its //! upper layers instead, under the key of its check: the push that takes //! the run takes its layers too, into new bases as its own run's would //! go, but only over the base each was written over. Applied over another, //! its whiteouts and what it left as it was would be of what that base //! held, not what this one does, so a layer over a base that has moved on //! is not kept, and the next run of the default branch warms it. Kept //! layers go once taken, once their pull has moved on ([`Caches::forget`]), //! and past [`KEPT_AGE`], [`KEPT_MAX`] of them or a budget of bytes, //! the oldest first. Only a run the caller says to keep is kept: an //! untrusted pull's never is, so its writes never reach a base. //! //! Where no push to the default branch runs, as on a runner that runs a //! repository's pulls alone, a trusted pull's run that passes makes the //! base instead ([`Lease::advance`]), as a push's would, so its pulls do //! not all start cold: but only over the base each layer was written //! over, still current, as a kept layer is taken, and only when the tip //! of the branch the pull merged with is the one that base was made at or //! newer, so the base never goes back to an older tip. Each base made so //! is marked with that tip's generation, in [`MARKS`]. //! //! The disk the caches are on is shared, with the site on the same host //! among others, so the runner keeps a floor of it free: below it, before //! a run starts, [`Caches::shed`] removes every kept run's layers and //! every base no run reads, and the runs after start afresh. //! //! Under the root: `bases//`, `runs///` with //! each layer's `upper` and the `work` directory overlayfs needs beside //! it, `kept//`, a kept run's [`KEPT`] and its `/upper`s, and //! [`MARKS`]. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::fs::Metadata; use std::io; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use crate::executor; use knotless::keys; /// The attribute overlayfs, mounted with `userxattr`, marks a directory in /// an upper layer with when it hides the lower layer's: one deleted and /// made again. const OPAQUE: &str = "user.overlay.opaque"; /// What a kept run's directory says of it. pub const KEPT: &str = "kept.json"; /// The generation of the branch's tip each cache's base a pull made was /// made at, as `{cache: [base generation, tip generation]}`. pub const MARKS: &str = "marks.json"; /// How long a passing pull run's layers are kept for a push to take, in /// seconds: three days. pub const KEPT_AGE: u64 = 3 * 24 * 60 * 60; /// The most runs whose layers are kept at once. pub const KEPT_MAX: usize = 16; /// Says the bytes free on the disk holding a path. pub type Space = Box io::Result + Send + Sync>; pub struct Caches { root: PathBuf, /// The bytes free on the disk the caches are on: [`free`], but in tests. space: Space, /// This runner's start, so its runs' names are not an earlier one's. boot: u128, runs: AtomicU64, bases: Mutex, /// Held while kept layers are made, taken or removed. kept: Mutex<()>, } /// A kept run's layers, when it was kept, and the bytes they take. #[derive(Serialize, Deserialize, Default)] struct Kept { at: u64, size: u64, layers: Vec, } /// A layer kept: its cache, where the run saw it, and the generation of /// the base it was written over. #[derive(Serialize, Deserialize)] struct KeptLayer { name: String, path: String, generation: u64, } /// A kept run's layers as a push takes them: a lease over the bases they /// were written over, of those still current, for [`Lease::promote`]; and /// where the run saw those over a base that has moved on, which are not. pub struct Taken { pub lease: Lease, pub moved: Vec, } impl Taken { /// Where the run saw each layer that is taken. pub fn kept(&self) -> Vec { self.lease.layers.iter().map(|l| l.path.clone()).collect() } } /// The generations of each cache's base: the one runs start from, those /// still on disk, and how many runs read each; and of the bases a pull /// made, the generation of the branch's tip each was made at. #[derive(Default)] struct Bases { current: HashMap, kept: HashMap>, readers: HashMap<(String, u64), usize>, next: u64, marks: HashMap, } impl Bases { /// Whether a layer written over `name`'s base `generation` by a pull /// merged with the branch's tip at generation `at` may make the next /// base: that base is still current, and was made at that tip or an /// older one, or by a push, which marks none. fn ahead(&self, name: &str, generation: u64, at: u64) -> bool { let mark = self.marks.get(name).filter(|(g, _)| *g == generation); self.current.get(name) == Some(&generation) && mark.is_none_or(|(_, tip)| *tip <= at) } /// The generations of `name` no run reads and none will start from, no /// longer counted as on disk. fn unused(&mut self, name: &str) -> Vec { let current = self.current.get(name).copied(); let readers = &self.readers; let kept = self.kept.entry(name.to_string()).or_default(); let unused: Vec = kept .iter() .copied() .filter(|g| Some(*g) != current && !readers.contains_key(&(name.to_string(), *g))) .collect(); kept.retain(|g| !unused.contains(g)); unused } } /// A cache as a run sees it: the base it starts from and the layer it /// writes to. #[derive(Clone, Debug)] pub struct Layer { /// The cache, as its bases are named. pub name: String, /// Where the run's container sees it. pub path: String, generation: u64, pub lower: PathBuf, pub upper: PathBuf, pub work: PathBuf, } /// A run's layers over the bases it reads, which stay until it ends; its /// own layers go then. pub struct Lease { caches: Arc, pub id: String, dir: PathBuf, pub layers: Vec, } impl Caches { /// The caches under `root`, with what runs of an earlier runner left /// there gone, but for the layers of the runs `alive` names, whose /// overlays are still mounted, and the bases those may read; and the /// layers kept of the runs `kept` names, whose checks a push may yet /// take, and of no other. pub fn open(root: &Path, alive: &[String], kept: &[String]) -> Result> { Caches::open_with(root, alive, kept, Box::new(free)) } /// [`Caches::open`], told the bytes free on its disk by `space`. pub fn open_with( root: &Path, alive: &[String], kept: &[String], space: Space, ) -> Result> { let (bases_dir, runs_dir) = (root.join("bases"), root.join("runs")); std::fs::create_dir_all(&bases_dir).context("making the caches' directory")?; std::fs::create_dir_all(&runs_dir)?; for run in std::fs::read_dir(&runs_dir)? { let run = run?; if !alive.iter().any(|a| run.file_name() == a.as_str()) { remove(&run.path())?; } } let kept_dir = root.join("kept"); std::fs::create_dir_all(&kept_dir)?; let wanted: HashSet = kept.iter().map(|r| keys::ref_hash(r)).collect(); for run in std::fs::read_dir(&kept_dir)? { let run = run?.path(); let named = run.file_name().map(|n| n.to_string_lossy().into_owned()); if !named.is_some_and(|n| wanted.contains(&n)) || read_kept(&run).is_none() { remove(&run)?; } } let mut bases = Bases::default(); for cache in std::fs::read_dir(&bases_dir)? { let cache = cache?; let name = cache.file_name().to_string_lossy().into_owned(); // An earlier runner's, named so that its repository cannot be // told, and so never dropped with it: its next run warms anew. if name.starts_with(PREFIX) && repo_of(&name).is_none() { remove(&cache.path())?; continue; } let mut gens = BTreeSet::new(); for base in std::fs::read_dir(cache.path())? { let base = base?; match base.file_name().to_string_lossy().parse::() { Ok(generation) => gens.insert(generation), // One half made when the runner stopped. Err(_) => remove(&base.path()).map(|_| false)?, }; } let Some(&newest) = gens.last() else { continue; }; bases.next = bases.next.max(newest + 1); bases.current.insert(name.clone(), newest); bases.kept.insert(name.clone(), gens); if alive.is_empty() { for old in bases.unused(&name) { remove(&cache.path().join(old.to_string()))?; } } } // Each mark of a base still current, which a pull made. let marks = std::fs::read(root.join(MARKS)).unwrap_or_default(); let marks: HashMap = serde_json::from_slice(&marks).unwrap_or_default(); bases.marks = marks .into_iter() .filter(|(name, (g, _))| bases.current.get(name) == Some(g)) .collect(); let boot = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); Ok(Arc::new(Caches { root: root.to_path_buf(), space, boot, runs: AtomicU64::new(0), bases: Mutex::new(bases), kept: Mutex::new(()), })) } /// Drops the caches of the repository `repo` ([`name`]), as of one /// deleted: each base no run reads now goes, and those that runs do go /// once they end, none being current any more. pub fn drop_repo(&self, repo: &str) -> Result<()> { let hash = keys::ref_hash(repo); let mut bases = self.bases.lock().unwrap(); let names: Vec = bases.kept.keys().cloned().collect(); let names = names.into_iter().filter(|n| repo_of(n) == Some(&hash)); for name in names.collect::>() { bases.current.remove(&name); bases.marks.remove(&name); let dir = self.root.join("bases").join(&name); for generation in bases.unused(&name) { remove(&dir.join(generation.to_string()))?; } // Empty, with no run reading it. let _ = std::fs::remove_dir(&dir); } Ok(()) } /// The bytes free on the disk the caches are on. pub fn free(&self) -> io::Result { (self.space)(&self.root) } /// Makes room on the disk, as it runs short: removes the layers kept /// of every run, which a push would only have saved a run's work /// with, and every cache's base no run reads, as [`Caches::drop_repo`] /// drops them, for the next run of each to start afresh. Says how /// many bases and kept runs went. pub fn shed(&self) -> Result<(usize, usize)> { let mut runs = 0; { let _kept = self.kept.lock().unwrap(); for run in std::fs::read_dir(self.root.join("kept"))? { remove(&run?.path())?; runs += 1; } } let mut bases = self.bases.lock().unwrap(); let mut gone = 0; let current: Vec<(String, u64)> = bases.current.clone().into_iter().collect(); for (name, generation) in current { if bases.readers.contains_key(&(name.clone(), generation)) { continue; } bases.current.remove(&name); bases.marks.remove(&name); for generation in bases.unused(&name) { remove(&self.base(&name, generation))?; gone += 1; } } Ok((gone, runs)) } /// Where the layers kept of the run `run` are. fn kept_dir(&self, run: &str) -> PathBuf { self.root.join("kept").join(keys::ref_hash(run)) } /// Keeps the layers `lease` wrote to, those with anything in them, as /// the run `run`'s, `now`, unless they would take more than `budget` /// bytes; then keeps the kept layers within their bounds. fn keeping(&self, lease: Lease, run: &str, budget: u64, now: u64) -> Result<()> { let _kept = self.kept.lock().unwrap(); let dir = self.kept_dir(run); let making = dir.with_extension("new"); remove(&dir)?; remove(&making)?; std::fs::create_dir_all(&making)?; let mut layers = Vec::new(); for layer in &lease.layers { if std::fs::read_dir(&layer.upper)?.next().is_none() { continue; } let to = making.join(layers.len().to_string()); std::fs::create_dir(&to)?; std::fs::rename(&layer.upper, to.join("upper"))?; layers.push(KeptLayer { name: layer.name.clone(), path: layer.path.clone(), generation: layer.generation, }); } let size = size(&making)?; if layers.is_empty() || size > budget { return Ok(remove(&making)?); } let kept = Kept { at: now, size, layers, }; std::fs::write(making.join(KEPT), serde_json::to_vec(&kept)?)?; std::fs::rename(&making, &dir)?; self.bound(budget, now) } /// Removes the kept layers past their bounds, `now`: older than /// [`KEPT_AGE`], and, the newest first, past [`KEPT_MAX`] of them or /// `budget` bytes in all. Called holding `kept`. fn bound(&self, budget: u64, now: u64) -> Result<()> { let mut all = Vec::new(); for run in std::fs::read_dir(self.root.join("kept"))? { let run = run?.path(); let kept = read_kept(&run).unwrap_or_default(); all.push((kept.at, kept.size, run)); } all.sort_by_key(|(at, ..)| std::cmp::Reverse(*at)); let (mut count, mut total) = (0, 0); for (at, size, run) in all { let fits = now.saturating_sub(at) <= KEPT_AGE && count < KEPT_MAX; if fits && total + size <= budget { (count, total) = (count + 1, total + size); } else { remove(&run)?; } } Ok(()) } /// Removes the layers kept of each of `runs`, as of runs a push can no /// longer take, and those past their bounds, as [`Caches::bound`]. pub fn forget(&self, runs: &[String], budget: u64) -> Result<()> { self.forgetting(runs, budget, now()) } /// [`Caches::forget`], `now`. fn forgetting(&self, runs: &[String], budget: u64, now: u64) -> Result<()> { let _kept = self.kept.lock().unwrap(); for run in runs { remove(&self.kept_dir(run))?; } self.bound(budget, now) } /// Takes the layers kept of the run `run`, for a push that takes its /// check: each over a base still current, leased over it, and where /// the run saw each over one that has moved on since, which is not. /// They go once the lease does, promoted or not. pub fn take(self: &Arc, run: &str) -> Taken { let _kept = self.kept.lock().unwrap(); let dir = self.kept_dir(run); let kept = read_kept(&dir).unwrap_or_default(); let mut bases = self.bases.lock().unwrap(); let (mut layers, mut moved) = (Vec::new(), Vec::new()); for (i, layer) in kept.layers.into_iter().enumerate() { let KeptLayer { name, path, generation, } = layer; if bases.current.get(&name) != Some(&generation) { moved.push(path); continue; } *bases.readers.entry((name.clone(), generation)).or_default() += 1; let at = dir.join(i.to_string()); layers.push(Layer { lower: self.base(&name, generation), name, path, generation, upper: at.join("upper"), work: at.join("work"), }); } let id = keys::ref_hash(run); let caches = self.clone(); let lease = Lease { caches, id, dir, layers, }; Taken { lease, moved } } /// Writes down `marks`, the tips the bases pulls made were made at. fn mark(&self, marks: &HashMap) -> Result<()> { let path = self.root.join(MARKS); let making = path.with_extension("new"); std::fs::write(&making, serde_json::to_vec(marks)?)?; Ok(std::fs::rename(&making, &path)?) } fn base(&self, name: &str, generation: u64) -> PathBuf { self.root .join("bases") .join(name) .join(generation.to_string()) } /// Layers for a run over the current base of each of `caches`, as /// `(name, path)`: an empty base for a cache that has none yet. pub fn lease(self: &Arc, caches: &[(String, String)]) -> Result { let n = self.runs.fetch_add(1, Ordering::Relaxed); let id = format!("{}-{n}", self.boot); let dir = self.root.join("runs").join(&id); let mut lease = Lease { caches: self.clone(), id, dir, layers: Vec::new(), }; for (i, (name, path)) in caches.iter().enumerate() { let generation = self.read(name)?; let layer = lease.dir.join(i.to_string()); lease.layers.push(Layer { name: name.clone(), path: path.clone(), generation, lower: self.base(name, generation), upper: layer.join("upper"), work: layer.join("work"), }); std::fs::create_dir_all(layer.join("upper"))?; std::fs::create_dir_all(layer.join("work"))?; } Ok(lease) } /// The generation of `name`'s base a run starts from, counted as read /// until it ends. fn read(&self, name: &str) -> Result { let mut bases = self.bases.lock().unwrap(); let generation = match bases.current.get(name) { Some(g) => *g, None => { let g = bases.next; std::fs::create_dir_all(self.base(name, g))?; bases.next += 1; bases.current.insert(name.to_string(), g); bases.kept.entry(name.to_string()).or_default().insert(g); g } }; *bases .readers .entry((name.to_string(), generation)) .or_default() += 1; Ok(generation) } /// Removes the bases of `name` that are no longer used. fn sweep(&self, name: &str) { let unused = self.bases.lock().unwrap().unused(name); for generation in unused { let _ = remove(&self.base(name, generation)); } } } impl Lease { /// The overlays to mount, one docker volume each, named for the run. pub fn mounts(&self) -> Vec { self.layers .iter() .enumerate() .map(|(i, l)| executor::Cache { volume: format!("knotless-overlay-{}-{i}", self.id), path: l.path.clone(), run: self.id.clone(), lower: l.lower.clone(), upper: l.upper.clone(), work: l.work.clone(), }) .collect() } /// Keeps what the run wrote, once its overlays are unmounted, as the /// run `run`'s, for a push that takes its check to take, within the /// bounds of kept layers and `budget` bytes for them all. pub async fn keep(self, run: &str, budget: u64) -> Result<()> { let (caches, run) = (self.caches.clone(), run.to_string()); let kept = move || caches.keeping(self, &run, budget, now()); tokio::task::spawn_blocking(kept).await? } /// Makes what the run wrote, once its overlays are unmounted, the base /// the runs after it start from: each layer it wrote to over its base, /// as a new one. A base that would be over `max` bytes is dropped /// instead, for the next run to start afresh. pub async fn promote(self, max: u64) -> Result<()> { self.promoting(max, None).await.map(|_| ()) } /// Makes what a trusted pull's run wrote, once its overlays are /// unmounted, the base the runs after it start from, as /// [`Lease::promote`] does a push's, where no push runs to: each layer /// only over the base it was written over, if that is still current /// and was made at the branch's tip of generation `at`, the one the /// pull merged with, or an older one. Says where the run saw the /// layers made the base, and those that are not. pub async fn advance(self, max: u64, at: u64) -> Result<(Vec, Vec)> { self.promoting(max, Some(at)).await } /// [`Lease::promote`], or, given the tip a pull merged with, `at`, /// [`Lease::advance`]. async fn promoting(self, max: u64, at: Option) -> Result<(Vec, Vec)> { let (caches, layers) = (self.caches.clone(), self.layers.clone()); let building = caches.clone(); let made = tokio::task::spawn_blocking(move || -> Result> { let caches = building; let mut made = Vec::new(); for layer in layers { if std::fs::read_dir(&layer.upper)?.next().is_none() { continue; } let generation = { let mut bases = caches.bases.lock().unwrap(); bases.next += 1; bases.next - 1 }; let base = caches.base(&layer.name, generation); let making = base.with_extension("new"); clone(&layer.lower, &making)?; apply(&layer.upper, &making)?; let fits = size(&making)? <= max; std::fs::rename(&making, &base)?; made.push((layer, generation, fits)); } Ok(made) }); let made = made.await??; let (mut kept, mut moved) = (Vec::new(), Vec::new()); for (layer, generation, fits) in made { let mut bases = caches.bases.lock().unwrap(); let name = layer.name.clone(); bases .kept .entry(name.clone()) .or_default() .insert(generation); // Checked as it is made current, so of two pulls' runs over // one base, the first to end makes the next, and the other's // goes. let ahead = at.is_none_or(|at| bases.ahead(&name, layer.generation, at)); if !ahead { moved.push(layer.path.clone()); } else if fits { bases.current.insert(name.clone(), generation); kept.push(layer.path.clone()); if let Some(at) = at { bases.marks.insert(name.clone(), (generation, at)); caches.mark(&bases.marks)?; } } else if bases.current.get(&name) == Some(&layer.generation) { bases.current.remove(&name); } drop(bases); caches.sweep(&name); } Ok((kept, moved)) } } impl Drop for Lease { fn drop(&mut self) { let _ = remove(&self.dir); let mut bases = self.caches.bases.lock().unwrap(); for layer in &self.layers { let key = (layer.name.clone(), layer.generation); let n = bases.readers.entry(key.clone()).or_default(); *n = n.saturating_sub(1); if *n == 0 { bases.readers.remove(&key); } } drop(bases); for layer in &self.layers { self.caches.sweep(&layer.name); } } } /// The bytes free to the runner on the disk holding `path`. pub fn free(path: &Path) -> io::Result { let stat = rustix::fs::statvfs(path)?; Ok(stat.f_bavail.saturating_mul(stat.f_frsize)) } /// The seconds since the epoch. fn now() -> u64 { let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH); since.unwrap_or_default().as_secs() } /// What the kept run's directory `dir` says of it, if it says. fn read_kept(dir: &Path) -> Option { let text = std::fs::read(dir.join(KEPT)).ok()?; serde_json::from_slice(&text).ok() } /// Removes `path`, a directory and all in it or anything else, if it is /// there. /// What every cache's name starts with. const PREFIX: &str = "knotless-cache-"; /// The name of a cache of the repository `repo`, `/`, /// that `what` tells apart from its others: a hash of each, so that the /// repository's caches can be told by name ([`Caches::drop_repo`]). pub fn name(repo: &str, what: &str) -> String { format!("{PREFIX}{}-{}", keys::ref_hash(repo), keys::ref_hash(what)) } /// The hash of the repository the cache `name` is of, as [`name`] makes /// one; none for a name it did not make. fn repo_of(name: &str) -> Option<&str> { let rest = name.strip_prefix(PREFIX)?; Some(rest.split_once('-')?.0) } fn remove(path: &Path) -> io::Result<()> { let removed = match std::fs::symlink_metadata(path) { Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path), Ok(_) => std::fs::remove_file(path), Err(err) => Err(err), }; match removed { Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), done => done, } } /// Makes `to` as `from` is, its directories new and everything else hard /// links to `from`'s. fn clone(from: &Path, to: &Path) -> io::Result<()> { std::fs::create_dir(to)?; for entry in std::fs::read_dir(from)? { let entry = entry?; let dest = to.join(entry.file_name()); if entry.file_type()?.is_dir() { clone(&entry.path(), &dest)?; } else { std::fs::hard_link(entry.path(), &dest)?; } } like(from, to) } /// Gives the directory `to` the permissions and owner `from` has. fn like(from: &Path, to: &Path) -> io::Result<()> { let meta = std::fs::symlink_metadata(from)?; std::fs::set_permissions(to, meta.permissions())?; std::os::unix::fs::lchown(to, Some(meta.uid()), Some(meta.gid())) } /// Applies the upper layer `upper` to `base`, as overlayfs shows the two /// together: each whiteout removes what it names, a directory not marked /// opaque merges with the one below it, and anything else replaces what /// is below it, moved rather than copied. fn apply(upper: &Path, base: &Path) -> io::Result<()> { for entry in std::fs::read_dir(upper)? { let entry = entry?; let (from, to) = (entry.path(), base.join(entry.file_name())); let meta = entry.metadata()?; let below = std::fs::symlink_metadata(&to).ok(); let merges = meta.is_dir() && !opaque(&from) && below.as_ref().is_some_and(Metadata::is_dir); if merges { apply(&from, &to)?; like(&from, &to)?; continue; } remove(&to)?; // A whiteout, once what it names is gone, has done its work. let moved = (!whiteout(&meta)).then(|| std::fs::rename(&from, &to)); moved.transpose()?; } Ok(()) } /// Whether `meta` is of a whiteout: a character device numbered 0, 0. fn whiteout(meta: &Metadata) -> bool { meta.file_type().is_char_device() && meta.rdev() == 0 } /// Whether the directory `dir` hides the one below it. fn opaque(dir: &Path) -> bool { let mut value = [0u8; 1]; let read = rustix::fs::lgetxattr(dir, OPAQUE, &mut value[..]); matches!(read, Ok(1)) && value[0] == b'y' } /// The bytes the files under `dir` take on disk, each file once however /// many names it has, as `du` counts them: cargo links what it builds /// under more than one. fn size(dir: &Path) -> io::Result { size_of(dir, &mut HashSet::new()) } /// [`size`], not counting again the files `seen`, by inode. fn size_of(dir: &Path, seen: &mut HashSet) -> io::Result { let mut total = 0; for entry in std::fs::read_dir(dir)? { let entry = entry?; let meta = entry.metadata()?; if meta.nlink() > 1 && !meta.is_dir() && !seen.insert(meta.ino()) { continue; } total += meta.blocks() * 512; if meta.is_dir() { total += size_of(&entry.path(), seen)?; } } Ok(total) } #[cfg(test)] mod tests { use super::*; fn write(path: &Path, text: &str) { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, text).unwrap(); } fn read(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } /// What `dir` holds, as `path=text` for each file, in order. fn listing(dir: &Path) -> Vec { let mut all = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).unwrap() { let p = e.unwrap().path(); if p.is_dir() { stack.push(p); } else { let rel = p.strip_prefix(dir).unwrap().display().to_string(); all.push(format!("{rel}={}", read(&p))); } } } all.sort(); all } /// Makes a whiteout at `path`, as overlayfs does: false where that /// takes privileges the tests do not have (macOS, not as root). fn whiteout_at(path: &Path) -> bool { let mut made = std::process::Command::new("mknod"); made.arg(path).args(["c", "0", "0"]); made.stderr(std::process::Stdio::null()) .status() .is_ok_and(|s| s.success()) } fn names(caches: &[(&str, &str)]) -> Vec<(String, String)> { caches .iter() .map(|(n, p)| (n.to_string(), p.to_string())) .collect() } #[tokio::test] async fn a_deleted_repositorys_caches_go() { let root = tempfile::tempdir().unwrap(); let bases = root.path().join("bases"); // An earlier runner's, named for no repository, goes at the start. let old = format!("{PREFIX}{}", keys::ref_hash("x")); std::fs::create_dir_all(bases.join(&old).join("0")).unwrap(); let caches = Caches::open(root.path(), &[], &[]).unwrap(); assert!(!bases.join(&old).exists()); let (gone, other) = (name("did:a/k", "t"), name("did:a/j", "t")); assert_eq!(repo_of(&gone), Some(keys::ref_hash("did:a/k").as_str())); assert_eq!(repo_of("t"), None); // One run reads the deleted repository's cache as it goes. let reading = caches.lease(&names(&[(gone.as_str(), "/a")])).unwrap(); let other_base = caches.lease(&names(&[(other.as_str(), "/a")])).unwrap(); let read = reading.layers[0].lower.clone(); caches.drop_repo("did:a/k").unwrap(); assert!(read.exists()); drop(reading); assert!(!read.exists()); // Another repository's stays. assert!(other_base.layers[0].lower.exists()); // One no run reads goes at once, its directory with it. let unread = caches.lease(&names(&[(gone.as_str(), "/a")])).unwrap(); let base = unread.layers[0].lower.clone(); drop(unread); caches.drop_repo("did:a/k").unwrap(); assert!(!base.exists()); assert!(!bases.join(&gone).exists()); } #[tokio::test] async fn runs_side_by_side_and_main_keeps() { let root = tempfile::tempdir().unwrap(); let caches = Caches::open(root.path(), &[], &[]).unwrap(); let wanted = names(&[("t", "/work/target"), ("r", "/usr/local/rustup")]); // Two runs at once, each over an empty base, with layers of its own. let a = caches.lease(&wanted).unwrap(); let b = caches.lease(&wanted).unwrap(); assert_ne!(a.id, b.id); assert_eq!(a.layers[0].lower, b.layers[0].lower); assert_ne!(a.layers[0].upper, b.layers[0].upper); let mounts = a.mounts(); assert_eq!(mounts[1].volume, format!("knotless-overlay-{}-1", a.id)); assert_eq!(mounts[1].path, "/usr/local/rustup"); assert_eq!(mounts[1].run, a.id); // What a pull writes is gone with it, and its layers with it. write(&b.layers[0].upper.join("pull"), "x"); let b_dir = b.dir.clone(); drop(b); assert!(!b_dir.exists()); // A main run that wrote makes a new base; one it did not write to // keeps its own. write(&a.layers[0].upper.join("debug/dep"), "1"); write(&a.layers[0].upper.join("debug/.fingerprint/f"), "1"); let first = a.layers[0].lower.clone(); let rustup = a.layers[1].lower.clone(); a.promote(1 << 30).await.unwrap(); assert!(!first.exists(), "an old base no run reads goes"); let c = caches.lease(&wanted).unwrap(); assert_eq!(c.layers[1].lower, rustup); let base = c.layers[0].lower.clone(); assert_eq!(listing(&base), ["debug/.fingerprint/f=1", "debug/dep=1"]); assert!(listing(&root.path().join("runs")).is_empty()); // A pull reading that base keeps it while a later main run makes // the next: overwritten, removed, and made again. let pull = caches.lease(&wanted).unwrap(); let up = &c.layers[0].upper; write(&up.join("debug/dep"), "2"); write(&up.join("new/file"), "n"); let whiteouts = whiteout_at(&up.join("debug/.fingerprint")); c.promote(1 << 30).await.unwrap(); assert_eq!( listing(&pull.layers[0].lower), ["debug/.fingerprint/f=1", "debug/dep=1"] ); let next = caches.lease(&wanted).unwrap(); // Where no whiteout could be made, what it would remove stays. let stays = ["debug/.fingerprint/f=1"] .into_iter() .filter(|_| !whiteouts); let mut want: Vec<&str> = stays.collect(); want.extend(["debug/dep=2", "new/file=n"]); assert_eq!(listing(&next.layers[0].lower), want); // Once the pull ends, the base it read goes. drop(pull); assert!(!base.exists()); // A directory made again hides what was below it; a file replaces // a directory, and a directory a file. let up = &next.layers[0].upper; write(&up.join("debug/fresh"), "f"); rustix::fs::lsetxattr( up.join("debug"), OPAQUE, b"y", rustix::fs::XattrFlags::empty(), ) .unwrap(); write(&up.join("new"), "now a file"); write(&up.join("lone"), "l"); next.promote(1 << 30).await.unwrap(); let last = caches.lease(&wanted).unwrap(); assert_eq!( listing(&last.layers[0].lower), ["debug/fresh=f", "lone=l", "new=now a file"] ); let up = &last.layers[0].upper; write(&up.join("lone/inside"), "i"); // Over the most a cache may hold, a base is dropped, and the next // run starts afresh. last.promote(1).await.unwrap(); let fresh = caches.lease(&wanted).unwrap(); assert!(listing(&fresh.layers[0].lower).is_empty()); let one = fresh.layers[1].lower.clone(); drop(fresh); let bases = root.path().join("bases"); assert_eq!(std::fs::read_dir(bases.join("t")).unwrap().count(), 1); // Opened again, the newest base of each is kept, and nothing else. write(&bases.join("t/99.new/half"), "h"); std::fs::create_dir_all(bases.join("t/0")).unwrap(); std::fs::create_dir_all(bases.join("empty")).unwrap(); write(&root.path().join("runs/old/0/upper/x"), "x"); std::fs::create_dir_all(root.path().join("runs/alive")).unwrap(); let again = Caches::open(root.path(), &[], &[]).unwrap(); assert!(!bases.join("t/99.new").exists()); assert!(!bases.join("t/0").exists()); assert_eq!( std::fs::read_dir(root.path().join("runs")).unwrap().count(), 0 ); let lease = again.lease(&wanted).unwrap(); assert_eq!(lease.layers[1].lower, one); drop(lease); // But for what runs whose overlays are still mounted read. std::fs::create_dir_all(bases.join("t/0")).unwrap(); std::fs::create_dir_all(root.path().join("runs/alive")).unwrap(); Caches::open(root.path(), &["alive".into()], &[]).unwrap(); assert!(bases.join("t/0").exists()); assert!(root.path().join("runs/alive").exists()); // A root it cannot make is an error. let file = root.path().join("file"); write(&file, ""); assert!(Caches::open(&file, &[], &[]).is_err()); assert!(remove(&root.path().join("gone")).is_ok()); // A file under two names counts once, as `du` counts it. let linked = root.path().join("linked"); write(&linked.join("one"), &"x".repeat(1 << 16)); let once = size(&linked).unwrap(); std::fs::hard_link(linked.join("one"), linked.join("two")).unwrap(); assert_eq!(size(&linked).unwrap(), once); } #[tokio::test] async fn pulls_advance_the_base_where_no_push_runs() { let root = tempfile::tempdir().unwrap(); let caches = Caches::open(root.path(), &[], &[]).unwrap(); let wanted = names(&[("t", "/work/target"), ("r", "/usr/local/rustup")]); let lower = |c: &Arc| { let lease = c.lease(&wanted).unwrap(); listing(&lease.layers[0].lower) }; // Two pulls' runs over one base, merged with the branch's tip at // generation 5: the first to end makes the next base, marked as of // that tip; the other's, over a base that has moved on, goes. let a = caches.lease(&wanted).unwrap(); let b = caches.lease(&wanted).unwrap(); write(&a.layers[0].upper.join("a"), "a"); write(&b.layers[0].upper.join("b"), "b"); let made = a.advance(1 << 30, 5).await.unwrap(); assert_eq!(made, (vec!["/work/target".to_string()], Vec::new())); let made = b.advance(1 << 30, 5).await.unwrap(); assert_eq!(made, (Vec::new(), vec!["/work/target".to_string()])); assert_eq!(lower(&caches), ["a=a"]); assert_eq!( std::fs::read_dir(root.path().join("bases/t")) .unwrap() .count(), 1 ); // One merged with an older tip never takes the base back to it, // though it ran over that base; one merged with the same or a // newer one goes on from it. let old = caches.lease(&wanted).unwrap(); write(&old.layers[0].upper.join("old"), "o"); assert_eq!( old.advance(1 << 30, 4).await.unwrap().0, Vec::::new() ); let same = caches.lease(&wanted).unwrap(); write(&same.layers[0].upper.join("same"), "s"); assert_eq!( same.advance(1 << 30, 5).await.unwrap().1, Vec::::new() ); assert_eq!(lower(&caches), ["a=a", "same=s"]); // The mark outlasts the runner: opened again, an older tip still // does not take it back, and a newer one goes on. let again = Caches::open(root.path(), &[], &[]).unwrap(); let old = again.lease(&wanted).unwrap(); write(&old.layers[0].upper.join("old"), "o"); assert!(old.advance(1 << 30, 4).await.unwrap().0.is_empty()); let newer = again.lease(&wanted).unwrap(); write(&newer.layers[0].upper.join("new"), "n"); newer.advance(1 << 30, 6).await.unwrap(); assert_eq!(lower(&again), ["a=a", "new=n", "same=s"]); // A push's base is marked with no tip, so any pull goes on from // it; and a mark of a base no longer current is let go on opening. let push = again.lease(&wanted).unwrap(); write(&push.layers[0].upper.join("push"), "p"); push.promote(1 << 30).await.unwrap(); let again = Caches::open(root.path(), &[], &[]).unwrap(); assert!(again.bases.lock().unwrap().marks.is_empty()); let after = again.lease(&wanted).unwrap(); write(&after.layers[0].upper.join("after"), "f"); after.advance(1 << 30, 1).await.unwrap(); assert_eq!( lower(&again), ["a=a", "after=f", "new=n", "push=p", "same=s"] ); // Over the most a cache may hold, the base is dropped, as a push's. let big = again.lease(&wanted).unwrap(); write(&big.layers[0].upper.join("big"), "b"); assert_eq!(big.advance(1, 9).await.unwrap(), (Vec::new(), Vec::new())); assert!(lower(&again).is_empty()); } /// The runs whose layers are kept, by the names their directories have. fn kept_of(root: &Path) -> Vec { let all = std::fs::read_dir(root.join("kept")).unwrap(); let mut all: Vec = all .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); all.sort(); all } #[tokio::test] async fn pulls_keep_what_a_push_takes() { let root = tempfile::tempdir().unwrap(); let caches = Caches::open(root.path(), &[], &[]).unwrap(); let wanted = names(&[("t", "/work/target"), ("r", "/usr/local/rustup")]); let main = caches.lease(&wanted).unwrap(); write(&main.layers[0].upper.join("dep"), "1"); main.promote(1 << 30).await.unwrap(); // A pull's run that passed keeps the layers it wrote to, and only // those, its own run's layers gone. let pull = caches.lease(&wanted).unwrap(); let (over, dir) = (pull.layers[0].lower.clone(), pull.dir.clone()); write(&pull.layers[0].upper.join("dep"), "2"); write(&pull.layers[0].upper.join("new"), "n"); pull.keep("pull-run", 1 << 30).await.unwrap(); assert!(!dir.exists()); assert_eq!(kept_of(root.path()), [keys::ref_hash("pull-run")]); // The push that takes its check takes them, into a new base over // the one it was written over, as its own run's would go. let taken = caches.take("pull-run"); assert_eq!(taken.kept(), ["/work/target"]); assert!(taken.moved.is_empty()); taken.lease.promote(1 << 30).await.unwrap(); assert!(kept_of(root.path()).is_empty()); assert!(!over.exists(), "the base it was over goes"); let next = caches.lease(&wanted).unwrap(); assert_eq!(listing(&next.layers[0].lower), ["dep=2", "new=n"]); drop(next); // A run nothing was kept of has none to take. let none = caches.take("never"); assert!(none.kept().is_empty() && none.moved.is_empty()); none.lease.promote(1 << 30).await.unwrap(); // One written over a base that has moved on since is not kept: its // writes and whiteouts are of what that base held. let stale = caches.lease(&wanted).unwrap(); write(&stale.layers[0].upper.join("stale"), "s"); write(&stale.layers[1].upper.join("toolchain"), "t"); let main = caches.lease(&wanted).unwrap(); write(&main.layers[0].upper.join("main"), "m"); stale.keep("stale-run", 1 << 30).await.unwrap(); main.promote(1 << 30).await.unwrap(); let taken = caches.take("stale-run"); assert_eq!(taken.kept(), ["/usr/local/rustup"]); assert_eq!(taken.moved, ["/work/target"]); taken.lease.promote(1 << 30).await.unwrap(); assert!(kept_of(root.path()).is_empty()); let next = caches.lease(&wanted).unwrap(); assert_eq!(listing(&next.layers[0].lower), ["dep=2", "main=m", "new=n"]); assert_eq!(listing(&next.layers[1].lower), ["toolchain=t"]); drop(next); // A run that wrote nothing keeps nothing, and nor does one over // the budget. let bare = caches.lease(&wanted).unwrap(); bare.keep("bare", 1 << 30).await.unwrap(); let big = caches.lease(&wanted).unwrap(); write(&big.layers[0].upper.join("big"), "b"); big.keep("big", 1).await.unwrap(); assert!(kept_of(root.path()).is_empty()); // Kept, they are bounded: in number, the oldest going first... let t0 = 1_000_000_000; let keep = |run: &str, at: u64, budget: u64| { let lease = caches.lease(&wanted).unwrap(); write(&lease.layers[0].upper.join(run), run); caches.keeping(lease, run, budget, at).unwrap(); }; for n in 0..=KEPT_MAX as u64 { keep(&format!("k{n}"), t0 + n, 1 << 30); } let all = kept_of(root.path()); assert_eq!(all.len(), KEPT_MAX); assert!(!all.contains(&keys::ref_hash("k0"))); // ...in age... let k1 = keys::ref_hash("k1"); let late = t0 + 2 + KEPT_AGE; caches.forgetting(&[], 1 << 30, late).unwrap(); assert!(!kept_of(root.path()).contains(&k1)); assert_eq!(kept_of(root.path()).len(), KEPT_MAX - 1); // ...and in bytes, the newest kept. let newest = format!("k{KEPT_MAX}"); let one = read_kept(&caches.kept_dir(&newest)).unwrap().size; caches.forgetting(&[], one, late).unwrap(); assert_eq!(kept_of(root.path()), [keys::ref_hash(&newest)]); // A run kept again replaces what it kept; one forgotten goes. keep(&newest, late, 1 << 30); assert_eq!(kept_of(root.path()), [keys::ref_hash(&newest)]); caches.forgetting(&[newest], 1 << 30, late).unwrap(); assert!(kept_of(root.path()).is_empty()); // Opened again, it keeps the layers of the runs it is told a push // may yet take, and nothing else. keep("wanted", late, 1 << 30); keep("unwanted", late, 1 << 30); std::fs::create_dir_all(caches.kept_dir("broken")).unwrap(); let half = caches.kept_dir("half").with_extension("new"); std::fs::create_dir_all(half).unwrap(); let named = ["wanted".into(), "broken".into()]; Caches::open(root.path(), &[], &named).unwrap(); assert_eq!(kept_of(root.path()), [keys::ref_hash("wanted")]); // A kept run's directory that says nothing of it counts for none. write(&root.path().join("kept/junk/0/upper/x"), "x"); caches.forgetting(&[], 1 << 30, late).unwrap(); assert_eq!(kept_of(root.path()), [keys::ref_hash("wanted")]); } #[tokio::test] async fn short_of_disk_it_sheds_what_no_run_reads() { let root = tempfile::tempdir().unwrap(); // The disk as statvfs tells it, of somewhere real. assert!(free(root.path()).unwrap() > 0); assert!(free(&root.path().join("gone")).is_err()); let space: Space = Box::new(|_| Ok(7)); let caches = Caches::open_with(root.path(), &[], &[], space).unwrap(); assert_eq!(caches.free().unwrap(), 7); let wanted = names(&[("read", "/a"), ("unread", "/b")]); // Bases for both caches, and a pull's run kept. let main = caches.lease(&wanted).unwrap(); write(&main.layers[0].upper.join("x"), "x"); write(&main.layers[1].upper.join("y"), "y"); main.promote(1 << 30).await.unwrap(); let pull = caches.lease(&wanted).unwrap(); write(&pull.layers[0].upper.join("z"), "z"); pull.keep("pull-run", 1 << 30).await.unwrap(); assert!(caches.kept_dir("pull-run").exists()); // A run reads one cache's base as the disk runs short. let reading = caches.lease(&names(&[("read", "/a")])).unwrap(); let (read, unread) = { let all = caches.lease(&wanted).unwrap(); (all.layers[0].lower.clone(), all.layers[1].lower.clone()) }; assert_eq!(caches.shed().unwrap(), (1, 1)); assert!(!caches.kept_dir("pull-run").exists()); assert!(!unread.exists()); assert!(read.exists()); // The next run of the one shed starts afresh; the one read goes // on from its base. let next = caches.lease(&wanted).unwrap(); assert_eq!(next.layers[0].lower, read); assert!(listing(&next.layers[1].lower).is_empty()); drop((next, reading)); // Nothing left to shed but the bases no run reads now. assert_eq!(caches.shed().unwrap(), (2, 0)); } }