OXYGENHUB

Coverage / crates/flow-init/src/prune.rs 89.8%

212 of 236 lines run, 61 of 70 functions · run #222 on main · run missed

1
//! Pruning a build cache before it is kept (oxygenflow#72, spec §5.4).
2
//!
3
//! A kept disk starts the next run, so without pruning it carries every
4
//! build's files: each crate's artifacts under every hash a lockfile or a
5
//! feature set ever gave it. Before a passing job's cache disk goes back
6
//! into the pool, the files this run did not use are removed, oldest
7
//! first, until what is on the disk is at most a share of its size (the
8
//! cap). The microVM's guest init prunes its disks before it powers off,
9
//! since only it can see inside them; the runner prunes the syscall
10
//! tier's directories itself. Both use this module.
11
//!
12
//! What the run used is told by the files' times against when the job
13
//! started: written (mtime), changed (ctime) or read (atime). Reads are
14
//! seen only where the file system records them: the guest mounts a cache
15
//! disk `strictatime`; on the runner, where a directory sits on whatever
16
//! file system the runner has, each file's atime is set to the epoch
17
//! before the job ([`forget_reads`]), so even `relatime` records its
18
//! first read. A file system mounted `noatime` records none, and then
19
//! nothing is pruned.
20
//!
21
//! A build tool does not read everything it relies on. Cargo, finding a
22
//! crate fresh, reads its fingerprint and only looks at its rlib and
23
//! binaries, so their times say nothing. Every name cargo gives a unit's
24
//! files carries the unit's hash (`.fingerprint/foo-<hash>/`,
25
//! `deps/libfoo-<hash>.rlib`, `build/foo-<hash>/`), so a file stays when
26
//! a file this run used carries one of the hashes in its path: a fresh
27
//! crate's fingerprint keeps its artifacts. A file with another link
28
//! that stays, or under a path the job's outputs name, stays too. What
29
//! goes is what this run's build did not touch, which a later build needs
30
//! only if an older lockfile comes back, and then cargo rebuilds it.
31
32
use serde::{Deserialize, Serialize};
33
#[cfg(unix)]
34
use std::collections::{HashMap, HashSet};
35
#[cfg(unix)]
36
use std::os::unix::fs::MetadataExt;
37
use std::path::Path;
38
#[cfg(unix)]
39
use std::path::PathBuf;
40
use std::time::{Duration, SystemTime};
41
42
/// The share of a cache disk a kept cache may fill when no runner says
43
/// otherwise: room for this run's build and as much again.
44
pub const DEFAULT_PERCENT: u8 = 60;
45
46
/// What pruning a cache found and did, in bytes of blocks on its disk.
47
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
48
pub struct Pruned {
49
    /// The disk's size, and the share of it the cache may fill.
50
    pub size: u64,
51
    pub percent: u8,
52
    /// What was on the disk before pruning, and after.
53
    pub before: u64,
54
    pub after: u64,
55
    /// What went: bytes and files.
56
    pub freed: u64,
57
    pub files: u64,
58
    /// Why nothing could be pruned, when so (no access times, or an
59
    /// error): only the size then decides.
60
    #[serde(default, skip_serializing_if = "Option::is_none")]
61
    pub unpruned: Option<String>,
62
}
63
64
impl Pruned {
65
    /// The most the cache may keep: `percent` of `size`.
6654×
    pub fn cap(&self) -> u64 {
6754×
        cap(self.size, self.percent)
6854×
    }
69
70
    /// Whether what is left may be kept.
7135×
    pub fn fits(&self) -> bool {
7235×
        self.after <= self.cap()
7335×
    }
74
}
75
7654×
fn cap(size: u64, percent: u8) -> u64 {
7754×
    (size as u128 * percent.min(100) as u128 / 100) as u64
7854×
}
79
80
/// Now, as a job's start to compare file times against. File systems
81
/// stamp times from a clock that may lag this one by a tick, so the
82
/// moments either side of it are left to pass: nothing done before the
83
/// call (such as [`forget_reads`]) can look as though it came after.
840×
pub fn start_clock() -> SystemTime {
850×
    std::thread::sleep(Duration::from_millis(20));
860×
    let t = SystemTime::now();
870×
    std::thread::sleep(Duration::from_millis(20));
880×
    t
890×
}
90
91
#[cfg(unix)]
92
/// Whether the file system holding `path` records reads (not `noatime`).
937×
pub fn records_reads(path: &Path) -> bool {
947×
    let Ok(c) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { return false };
957×
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
967×
    if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
970×
        return false;
987×
    }
99
    // Only Linux's statvfs has a `noatime` flag: elsewhere (Haiku, FreeBSD)
100
    // reads are taken as recorded, and the access-time check decides.
101
    #[cfg(any(target_os = "linux", target_os = "android"))]
1027×
    return st.f_flag & libc::ST_NOATIME == 0;
103
    #[cfg(not(any(target_os = "linux", target_os = "android")))]
104
    true
1057×
}
106
107
#[cfg(unix)]
108
/// The blocks in use on the file system mounted at `path`, and its size
109
/// (as `df` counts them).
1100×
pub fn usage(path: &Path) -> std::io::Result<(u64, u64)> {
1110×
    let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
1120×
    let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
1130×
    if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
1140×
        return Err(std::io::Error::last_os_error());
1150×
    }
1160×
    let unit = st.f_frsize as u64;
1170×
    Ok(((st.f_blocks as u64).saturating_sub(st.f_bfree as u64) * unit, st.f_blocks as u64 * unit))
1180×
}
119
120
#[cfg(unix)]
121
/// Set every regular file's atime under `root` to the epoch, its mtime
122
/// kept, so the job's first read of each is recorded even on `relatime`
123
/// (which records a read only of a file read no later than it changed).
124
/// Links are not followed. The count of files.
1252×
pub fn forget_reads(root: &Path) -> std::io::Result<u64> {
1262×
    let mut n = 0;
1272×
    let mut err = None;
1286×
    walk(root, &mut |p, md| {
1296×
        if !md.file_type().is_file() {
1304×
            return;
1312×
        }
1322×
        let Ok(c) = std::ffi::CString::new(p.as_os_str().as_encoded_bytes()) else { return };
1332×
        let times = [libc::timespec { tv_sec: 0, tv_nsec: 0 }, libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_OMIT }];
1342×
        if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) } == 0 {
1352×
            n += 1;
1362×
        } else if err.is_none() {
1370×
            err = Some(std::io::Error::last_os_error());
1380×
        }
1396×
    })?;
1402×
    match err {
1410×
        Some(e) => Err(e),
1422×
        None => Ok(n),
143
    }
1442×
}
145
146
#[cfg(unix)]
147
/// Each entry under `root` (not `root` itself) with its metadata, parents
148
/// before children; links are not followed, nor other file systems
149
/// entered.
1506×
fn walk(root: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) -> std::io::Result<()> {
1516×
    let dev = std::fs::symlink_metadata(root)?.dev();
1526×
    let mut dirs = vec![root.to_path_buf()];
15338×
    while let Some(d) = dirs.pop() {
15432×
        let Ok(entries) = std::fs::read_dir(&d) else { continue };
15551×
        for e in entries.flatten() {
15651×
            let p = e.path();
15751×
            let Ok(md) = std::fs::symlink_metadata(&p) else { continue };
15851×
            if md.dev() != dev {
1590×
                continue;
16051×
            }
16151×
            f(&p, &md);
16251×
            if md.is_dir() {
16326×
                dirs.push(p);
16426×
            }
165
        }
166
    }
1676×
    Ok(())
1686×
}
169
170
#[cfg(unix)]
171
/// The unit hashes in `rel`: a 16-digit lowercase hex word after a `-`
172
/// in any of its names, as cargo names a unit's files.
17329×
fn hashes(rel: &Path) -> impl Iterator<Item = String> + '_ {
17489×
    rel.components().filter_map(|c| c.as_os_str().to_str()).flat_map(|name| {
17589×
        name.split('-').skip(1).filter_map(|w| {
17633×
            let w = w.split('.').next().unwrap_or("");
177347×
            (w.len() == 16 && w.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))).then(|| w.to_string())
17833×
        })
17989×
    })
18029×
}
181
182
#[cfg(unix)]
183106×
fn nanos(s: i64, ns: i64) -> i128 {
184106×
    s as i128 * 1_000_000_000 + ns as i128
185106×
}
186
187
#[cfg(unix)]
188
/// One file, however many names it has.
189
struct File {
190
    paths: Vec<PathBuf>,
191
    links: u64,
192
    bytes: u64,
193
    /// When it was last used as far as can be told: the later of its
194
    /// atime and mtime.
195
    last: i128,
196
    used: bool,
197
}
198
199
#[cfg(unix)]
200
/// Prune the cache at `root`, a disk of `size` bytes with `before` in
201
/// use, down to `percent` of its size, removing only regular files this
202
/// run did not use (see the module's notes), those used longest ago
203
/// first. `started` is when the job started ([`start_clock`]); `keep` are
204
/// paths relative to `root` (the job's outputs) that stay whatever their
205
/// times. Under the cap nothing is touched. `after` is `before` less what
206
/// was freed.
20710×
pub fn prune(root: &Path, started: SystemTime, keep: &[String], size: u64, before: u64, percent: u8) -> std::io::Result<Pruned> {
20810×
    let mut out = Pruned { size, percent, before, after: before, ..Pruned::default() };
20910×
    if before <= out.cap() {
2106×
        return Ok(out);
2114×
    }
2124×
    let since = started.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos() as i128).unwrap_or(0);
2134×
    let keep: Vec<PathBuf> = keep.iter().map(|k| PathBuf::from(k.trim_start_matches('/'))).filter(|k| !k.as_os_str().is_empty()).collect();
2144×
    let mut files: HashMap<(u64, u64), File> = HashMap::new();
21545×
    walk(root, &mut |p, md| {
21645×
        if !md.file_type().is_file() {
21723×
            return;
21822×
        }
21922×
        let rel = p.strip_prefix(root).unwrap_or(p);
22022×
        let used = [nanos(md.atime(), md.atime_nsec()), nanos(md.mtime(), md.mtime_nsec()), nanos(md.ctime(), md.ctime_nsec())]
22122×
            .into_iter()
22245×
            .any(|t| t >= since)
22311×
            || keep.iter().any(|k| rel.starts_with(k));
22422×
        let f = files.entry((md.dev(), md.ino())).or_insert_with(|| File {
22520×
            paths: Vec::new(),
22620×
            links: md.nlink(),
22720×
            bytes: md.blocks() * 512,
22820×
            last: nanos(md.atime(), md.atime_nsec()).max(nanos(md.mtime(), md.mtime_nsec())),
229
            used: false,
23020×
        });
23122×
        f.paths.push(p.to_path_buf());
23222×
        f.used |= used;
23345×
    })?;
234
    // A hash any used file carries keeps every file carrying it.
2354×
    let live: HashSet<String> =
23613×
        files.values().filter(|f| f.used).flat_map(|f| f.paths.iter().flat_map(|p| hashes(p.strip_prefix(root).unwrap_or(p)))).collect();
2374×
    let mut gone: Vec<File> = files
2384×
        .into_values()
23920×
        .filter(|f| {
24020×
            !f.used
241
                // A link from outside the cache: removing it frees nothing.
2429×
                && f.links == f.paths.len() as u64
2439×
                && !f.paths.iter().any(|p| hashes(p.strip_prefix(root).unwrap_or(p)).any(|h| live.contains(&h)))
24420×
        })
2454×
        .collect();
2464×
    gone.sort_by_key(|f| f.last);
2474×
    let mut emptied: HashSet<PathBuf> = HashSet::new();
2485×
    for f in gone {
2495×
        if out.after <= out.cap() {
2501×
            break;
2514×
        }
2524×
        let mut removed = true;
2534×
        for p in &f.paths {
2544×
            if std::fs::remove_file(p).is_err() {
2550×
                removed = false;
2564×
            } else if let Some(parent) = p.parent() {
2574×
                emptied.insert(parent.to_path_buf());
2584×
            }
259
        }
2604×
        if removed {
2614×
            out.after = out.after.saturating_sub(f.bytes);
2624×
            out.freed += f.bytes;
2634×
            out.files += 1;
2644×
        }
265
    }
266
    // Directories pruning emptied go too, deepest first; never `root`.
2674×
    let mut dirs: Vec<PathBuf> = emptied.into_iter().collect();
2684×
    dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
2694×
    for d in dirs {
2704×
        let mut d = d.as_path();
2715×
        while d != root && d.starts_with(root) && std::fs::remove_dir(d).is_ok() {
2721×
            match d.parent() {
2731×
                Some(p) => d = p,
2740×
                None => break,
275
            }
276
        }
277
    }
2784×
    Ok(out)
27910×
}
280
281
// Elsewhere (a Windows runner, which has no microVM tier and so no cache
282
// disks to prune) nothing records reads, so nothing is pruned.
283
284
#[cfg(not(unix))]
285
pub fn records_reads(_path: &Path) -> bool {
286
    false
287
}
288
289
#[cfg(not(unix))]
290
pub fn usage(_path: &Path) -> std::io::Result<(u64, u64)> {
291
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
292
}
293
294
#[cfg(not(unix))]
295
pub fn forget_reads(_root: &Path) -> std::io::Result<u64> {
296
    Ok(0)
297
}
298
299
#[cfg(not(unix))]
300
pub fn prune(_root: &Path, _started: SystemTime, _keep: &[String], _size: u64, _before: u64, _percent: u8) -> std::io::Result<Pruned> {
301
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
302
}
303
304
#[cfg(all(test, unix))]
305
mod tests {
306
    use super::*;
307
308
    /// Times around a job that started at `T`, ahead of the clock: a file
309
    /// whose times are set is changed (ctime) now, before it.
310
    const T: u64 = 4_000_000_000;
311
31226×
    fn at(secs: u64) -> SystemTime {
31326×
        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
31426×
    }
315
316
    /// A file of `kib` KiB at `rel`, last read and written at `secs`.
31711×
    fn file(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf {
31811×
        let p = root.join(rel);
31911×
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3201314816×
        let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
32111×
        std::fs::write(&p, bytes).unwrap();
32211×
        let f = std::fs::File::options().write(true).open(&p).unwrap();
32311×
        f.set_times(std::fs::FileTimes::new().set_accessed(at(secs)).set_modified(at(secs))).unwrap();
32411×
        p
32511×
    }
326
32711×
    fn bytes(p: &Path) -> u64 {
32811×
        std::fs::metadata(p).unwrap().blocks() * 512
32911×
    }
330
331
    #[test]
3321×
    fn hashes_are_cargos_unit_hashes() {
3337×
        let h = |p: &str| hashes(Path::new(p)).collect::<Vec<_>>();
3341×
        assert_eq!(h("debug/deps/libdep-8f2b19826c505967.rlib"), ["8f2b19826c505967"]);
3351×
        assert_eq!(h("debug/.fingerprint/dep-8f2b19826c505967/lib-dep"), ["8f2b19826c505967"]);
3361×
        assert_eq!(h("debug/build/my-crate-0123456789abcdef/out/x.o"), ["0123456789abcdef"]);
3371×
        assert!(h("debug/incremental/app-2dadnjnzlrswc/s-hndl5uwdpw-1uaho4m.lock").is_empty());
3381×
        assert!(h("debug/app").is_empty() && h("debug/deps/x-0123456789ABCDEF").is_empty() && h("x-0123456789abcde").is_empty());
3391×
    }
340
341
    #[test]
3421×
    fn under_the_cap_nothing_is_touched() {
3431×
        let dir = tempfile::tempdir().unwrap();
3441×
        let old = file(dir.path(), "debug/deps/libold-1111111111111111.rlib", 64, 1_000);
3451×
        let p = prune(dir.path(), at(T), &[], 1 << 30, 64 << 10, 60).unwrap();
3461×
        assert!(old.exists() && p.fits() && p.freed == 0 && p.after == p.before, "{p:?}");
3471×
    }
348
349
    /// A cargo target as a run leaves it: this lockfile's units used this
350
    /// run (one rebuilt, one fresh: only its fingerprint read), an older
351
    /// lockfile's not, and an output the run did not touch.
352
    #[test]
3531×
    fn what_this_run_did_not_use_goes_oldest_first_and_its_units_stay_whole() {
3541×
        let dir = tempfile::tempdir().unwrap();
3551×
        let root = dir.path();
3561×
        let app = file(root, "debug/deps/app-aaaaaaaaaaaaaaaa", 256, T + 100);
3571×
        let app_fp = file(root, "debug/.fingerprint/app-aaaaaaaaaaaaaaaa/bin-app", 1, T + 100);
3581×
        let dep_rlib = file(root, "debug/deps/libdep-bbbbbbbbbbbbbbbb.rlib", 256, T - 5_000);
3591×
        let dep_fp = file(root, "debug/.fingerprint/dep-bbbbbbbbbbbbbbbb/lib-dep", 1, T - 5_000);
3601×
        std::fs::File::open(&dep_fp).unwrap().set_times(std::fs::FileTimes::new().set_accessed(at(T + 50))).unwrap();
3611×
        let script = file(root, "debug/build/dep-bbbbbbbbbbbbbbbb/out/gen.rs", 64, T - 5_000);
362
        // Uplifted: another name of the rebuilt binary.
3631×
        std::fs::hard_link(&app, root.join("debug/app")).unwrap();
364
        // An older lockfile's units, untouched, the oldest first to go.
3651×
        let oldest = file(root, "debug/deps/libdep-cccccccccccccccc.rlib", 256, T - 9_000);
3661×
        let oldest_fp = file(root, "debug/.fingerprint/dep-cccccccccccccccc/lib-dep", 1, T - 8_999);
3671×
        let older = file(root, "debug/deps/libdep-dddddddddddddddd.rlib", 256, T - 8_000);
368
        // A declared output, older still, that the run did not touch.
3691×
        let output = file(root, "release/tool", 128, T - 10_000);
3701×
        let all = [&app, &app_fp, &dep_rlib, &dep_fp, &script, &oldest, &oldest_fp, &older, &output];
3719×
        let before: u64 = all.iter().map(|p| bytes(p)).sum();
372
        // Room for all but the oldest unit.
3731×
        let want = before - bytes(&oldest) - bytes(&oldest_fp);
3741×
        let size = (want * 100).div_ceil(60);
3751×
        let p = prune(root, at(T), &["/release/tool".into()], size, before, 60).unwrap();
3761×
        assert!(p.fits(), "{p:?}");
3771×
        assert!(
3781×
            !oldest.exists() && !root.join("debug/.fingerprint/dep-cccccccccccccccc").exists(),
379
            "the oldest unit went, its emptied directory too"
380
        );
3811×
        assert!(older.exists(), "and pruning stopped under the cap: {p:?}");
3827×
        for kept in [&app, &app_fp, &dep_rlib, &dep_fp, &script, &output, &root.join("debug/app")] {
3837×
            assert!(kept.exists(), "{} stays", kept.display());
384
        }
3851×
        assert_eq!((p.files, p.freed, p.after), (2, p.before - want, want));
386
        // With no room at all: still only what the run did not use goes.
3871×
        let p = prune(root, at(T), &["release/tool".into()], 1, p.after, 60).unwrap();
3881×
        assert!(!older.exists() && !p.fits() && p.files == 1, "{p:?}");
3895×
        for kept in [&app, &dep_rlib, &dep_fp, &script, &output] {
3905×
            assert!(kept.exists(), "{} stays", kept.display());
391
        }
3921×
        assert!(root.join("debug/deps").is_dir(), "a directory still holding files stays");
3931×
    }
394
395
    #[test]
3961×
    fn reads_are_forgotten_so_the_next_is_seen() {
3971×
        let dir = tempfile::tempdir().unwrap();
3981×
        let p = file(dir.path(), "a/b", 1, 5_000);
3991×
        std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
4001×
        assert_eq!(forget_reads(dir.path()).unwrap(), 1);
4011×
        let md = std::fs::metadata(&p).unwrap();
4021×
        assert_eq!((md.atime(), md.mtime()), (0, 5_000), "the mtime is kept");
4031×
        assert!(std::fs::symlink_metadata(dir.path().join("link")).unwrap().atime() > 0, "a link is not followed");
4041×
    }
405
}