OXYGENHUB
# 749d6e7168b8 oxygenflow / crates / flow / src / cache.rs
15.8 KiB · Rust · last changed Claims say when each build cache was last kept 22 days ago source blame history raw
1
//! The build cache (oxygenflow#28, spec §5.4): a disk mount that names a
2
//! `cache` is started from a disk kept by an earlier passing job, rather
3
//! than empty, so a build carries its compiled dependencies forward.
4
//!
5
//! Each runner keeps a pool under `<home>/cache`, by repository, job,
6
//! cache name and bookmark, each disk named by its key: the BLAKE3 of the
7
//! image, the job, the cache name and the content of its key files. A job
8
//! takes, in order:
9
//!
10
//! 1. its bookmark's disk with the same key (moved: no copying);
11
//! 2. its bookmark's newest disk (moved; cargo rebuilds what changed);
12
//! 3. another bookmark's disk with the same key, then its newest (copied,
13
//!    so a proposal never takes main's cache away);
14
//! 4. else an empty disk, as without a cache.
15
//!
16
//! A passing job's disk goes back into its bookmark's place; a failing
17
//! job's is thrown away, so nothing it wrote reaches a later job. The
18
//! receipt names what the job started from. The pool is kept under its
19
//! size limit by dropping the disks used longest ago.
20
//!
21
//! Beside each disk, `<key>.sources` lists the sources it was built from
22
//! (oxygenflow#52): each file's content hash and the mtime it had. A job
23
//! that takes the disk gives every file whose content is unchanged that
24
//! mtime back, so a build tool that judges freshness by time (cargo)
25
//! rebuilds only what changed; a changed file keeps the time of its
26
//! checkout, newer than anything on the disk, so it is always rebuilt.
27
28
use anyhow::{Context, Result};
29
use flow_core::pipeline::Mount;
30
use std::os::unix::fs::MetadataExt;
31
use std::path::{Path, PathBuf};
32
use std::time::SystemTime;
33
34
/// The pool's default size limit.
35
pub const DEFAULT_GB: u64 = 64;
36
37
/// The sources a cached disk was built from: path to content hash and
38
/// mtime, in whole seconds (what the workspace image keeps).
39
pub type Sources = std::collections::BTreeMap<String, (String, i64)>;
40
41
/// What a job started a cached disk from.
42
pub struct Taken {
43
    /// For the log and the receipt.
44
    pub how: String,
45
    /// The sources it was built from, when they were kept with it.
46
    pub sources: Option<Sources>,
47
}
48
49
fn sources_of(disk: &Path) -> PathBuf {
50
    disk.with_extension("sources")
51
}
52
53
fn read_sources(disk: &Path) -> Option<Sources> {
54
    serde_json::from_slice(&std::fs::read(sources_of(disk)).ok()?).ok()
55
}
56
57
/// Where a job's cached disks come from and go back to.
58
pub struct Pool {
59
    root: PathBuf,
60
    limit: u64,
61
}
62
63
/// Which job: the pool's coordinates other than the key.
64
pub struct Ctx<'a> {
65
    pub repo: &'a str,
66
    pub bookmark: &'a str,
67
    pub job: &'a str,
68
    pub image: &'a str,
69
}
70
71
use flow_core::api::segment;
72
73
impl Pool {
74
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
75
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
76
    }
77
78
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
79
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
80
    }
81
82
    /// A mount's key: the image, job, cache name and its key files'
83
    /// content hashes (`hash_of` gives a file's, `None` when absent).
84
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
85
        let mut h = blake3::Hasher::new();
86
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
87
            h.update(part.as_bytes());
88
            h.update(b"\n");
89
        }
90
        for k in &m.key {
91
            h.update(k.as_bytes());
92
            h.update(b"=");
93
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
94
            h.update(b"\n");
95
        }
96
        h.finalize().to_hex()[..32].to_string()
97
    }
98
99
    /// Start `dest` from the pool for mount `m`, a disk of `m.size_mib`.
100
    /// What it started from, or `None` (the caller makes an empty disk).
101
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> {
102
        let name = m.cache.as_deref()?;
103
        let dir = self.cache_dir(ctx, name);
104
        let size = m.size_mib as u64 * (1 << 20);
105
        let fits = |p: &Path| std::fs::metadata(p).is_ok_and(|md| md.len() == size);
106
        let mine = dir.join(segment(ctx.bookmark));
107
        let exact = mine.join(format!("{key}.ext4"));
108
        // A moved disk takes its sources with it.
109
        if fits(&exact) {
110
            let sources = read_sources(&exact);
111
            if std::fs::rename(&exact, dest).is_ok() {
112
                let _ = std::fs::remove_file(sources_of(&exact));
113
                return Some(Taken { how: format!("cache {name}: this bookmark's, same key"), sources });
114
            }
115
        }
116
        if let Some(p) = newest(&mine, |p| fits(p)) {
117
            let sources = read_sources(&p);
118
            if std::fs::rename(&p, dest).is_ok() {
119
                let _ = std::fs::remove_file(sources_of(&p));
120
                return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources });
121
            }
122
        }
123
        let others: Vec<PathBuf> =
124
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
125
        let pick = others
126
            .iter()
127
            .map(|d| d.join(format!("{key}.ext4")))
128
            .filter(|p| fits(p))
129
            .max_by_key(|p| modified(p))
130
            .map(|p| (p, "same key"))
131
            .or_else(|| others.iter().filter_map(|d| newest(d, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key")));
132
        if let Some((p, which)) = pick {
133
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
134
            if sparse_copy(&p, dest).is_ok() {
135
                return Some(Taken {
136
                    how: format!("cache {name}: copied from another bookmark's ({from}), {which}"),
137
                    sources: read_sources(&p),
138
                });
139
            }
140
        }
141
        None
142
    }
143
144
    /// Keep a passing job's disk for mount `m` under its key, then keep
145
    /// the pool under its limit.
146
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> {
147
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
148
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
149
        std::fs::create_dir_all(&mine)?;
150
        let dest = mine.join(format!("{key}.ext4"));
151
        if std::fs::rename(disk, &dest).is_err() {
152
            sparse_copy(disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
153
        }
154
        // Moved disks keep their old time: mark it used now.
155
        std::fs::File::options().write(true).open(&dest)?.set_modified(SystemTime::now())?;
156
        let tmp = sources_of(&dest).with_extension("sources-part");
157
        std::fs::write(&tmp, serde_json::to_vec(sources)?)?;
158
        std::fs::rename(&tmp, sources_of(&dest))?;
159
        // One disk per key is enough; older keys of this bookmark go once
160
        // the pool is over its limit.
161
        self.evict();
162
        Ok(())
163
    }
164
165
    /// The repository and job slots holding at least one disk, as
166
    /// [`flow_core::api::cache_slot`] names them, for the claim
167
    /// (oxygenflow#51), each with when its newest disk was kept (Unix ms):
168
    /// a disk's time is set when it is kept, so the newest is the freshest
169
    /// build (oxygenflow#54).
170
    pub fn slot_times(&self) -> std::collections::BTreeMap<String, u64> {
171
        let mut out = std::collections::BTreeMap::new();
172
        for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() {
173
            for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() {
174
                let mut newest: Option<SystemTime> = None;
175
                walk(&job.path(), &mut |p| {
176
                    if p.extension().is_some_and(|e| e == "ext4") {
177
                        let t = modified(p);
178
                        newest = Some(newest.map_or(t, |n| n.max(t)));
179
                    }
180
                });
181
                if let Some(t) = newest {
182
                    let ms = t.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
183
                    out.insert(format!("{}/{}", repo.file_name().to_string_lossy(), job.file_name().to_string_lossy()), ms);
184
                }
185
            }
186
        }
187
        out
188
    }
189
190
    /// Drop the disks used longest ago until the pool's blocks fit.
191
    pub fn evict(&self) -> u64 {
192
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
193
        walk(&self.root, &mut |p| {
194
            if p.extension().is_some_and(|e| e == "ext4") {
195
                if let Ok(md) = std::fs::metadata(p) {
196
                    disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), md.blocks() * 512, p.to_path_buf()));
197
                }
198
            }
199
        });
200
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
201
        disks.sort();
202
        let mut freed = 0;
203
        for (_, bytes, p) in disks {
204
            if total <= self.limit {
205
                break;
206
            }
207
            if std::fs::remove_file(&p).is_ok() {
208
                let _ = std::fs::remove_file(sources_of(&p));
209
                total -= bytes;
210
                freed += bytes;
211
            }
212
        }
213
        freed
214
    }
215
}
216
217
fn modified(p: &Path) -> SystemTime {
218
    std::fs::metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
219
}
220
221
fn newest(dir: &Path, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
222
    std::fs::read_dir(dir)
223
        .ok()?
224
        .flatten()
225
        .map(|e| e.path())
226
        .filter(|p| p.extension().is_some_and(|e| e == "ext4") && ok(p))
227
        .max_by_key(|p| modified(p))
228
}
229
230
fn walk(dir: &Path, f: &mut dyn FnMut(&Path)) {
231
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
232
        let p = e.path();
233
        if p.is_dir() {
234
            walk(&p, f);
235
        } else {
236
            f(&p);
237
        }
238
    }
239
}
240
241
/// Copy a sparse disk without writing its holes.
242
fn sparse_copy(from: &Path, to: &Path) -> Result<()> {
243
    let tmp = to.with_extension("part");
244
    let st = std::process::Command::new("cp").arg("--sparse=always").arg(from).arg(&tmp).status().context("run cp")?;
245
    if !st.success() {
246
        let _ = std::fs::remove_file(&tmp);
247
        anyhow::bail!("cp {} failed", from.display());
248
    }
249
    std::fs::rename(&tmp, to)?;
250
    Ok(())
251
}
252
253
#[cfg(test)]
254
mod tests {
255
    use super::*;
256
    use flow_core::pipeline::MountSource;
257
258
    fn mount(key: &[&str]) -> Mount {
259
        Mount {
260
            source: MountSource::Disk,
261
            target: "/src/target".into(),
262
            size_mib: 1,
263
            cache: Some("target".into()),
264
            key: key.iter().map(|s| s.to_string()).collect(),
265
        }
266
    }
267
268
    fn disk(path: &Path, fill: &[u8]) {
269
        let f = std::fs::File::create(path).unwrap();
270
        f.set_len(1 << 20).unwrap();
271
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
272
    }
273
274
    fn first(path: &Path) -> Vec<u8> {
275
        std::fs::read(path).unwrap()[..4].to_vec()
276
    }
277
278
    #[test]
279
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
280
        let home = tempfile::tempdir().unwrap();
281
        let work = tempfile::tempdir().unwrap();
282
        let pool = Pool::new(home.path(), None);
283
        let m = mount(&["Cargo.lock"]);
284
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img" };
285
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
286
        // Nothing yet: the caller makes an empty disk.
287
        assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none());
288
        // A passing job's disk is kept and moved into the next job's place.
289
        disk(&work.path().join("a.ext4"), b"MAIN");
290
        let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into();
291
        pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap();
292
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
293
        let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
294
        assert!(taken.how.contains("same key"), "{}", taken.how);
295
        assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)");
296
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
297
        pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap();
298
        // A changed key takes the bookmark's newest disk all the same.
299
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
300
        assert_ne!(key, key2);
301
        let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
302
        assert!(taken.how.contains("older key"), "{}", taken.how);
303
        assert_eq!(taken.sources.as_ref(), Some(&built));
304
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap();
305
        assert_eq!(
306
            pool.slot_times().into_keys().collect::<Vec<_>>(),
307
            vec![flow_core::api::cache_slot("andy/hub", "test")],
308
            "what the claim reports (#51)"
309
        );
310
        // With when it was last kept (#54): just now.
311
        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64;
312
        let kept = pool.slot_times()[&flow_core::api::cache_slot("andy/hub", "test")];
313
        assert!(now.abs_diff(kept) < 60_000, "kept {kept}, now {now}");
314
        // Another bookmark copies it, leaving main's in place.
315
        let feature = Ctx { bookmark: "feature", ..main };
316
        let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
317
        assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how);
318
        assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them");
319
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
320
        let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap();
321
        assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources");
322
        // Another job or a different size never shares it.
323
        let other_job = Ctx { job: "build", ..main };
324
        assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none());
325
        let mut bigger = mount(&["Cargo.lock"]);
326
        bigger.size_mib = 2;
327
        assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none());
328
    }
329
330
    #[test]
331
    fn the_key_follows_the_image_the_job_and_the_key_files() {
332
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1" };
333
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
334
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
335
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
336
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
337
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
338
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
339
    }
340
341
    #[test]
342
    fn the_pool_drops_the_disks_used_longest_ago() {
343
        let home = tempfile::tempdir().unwrap();
344
        let work = tempfile::tempdir().unwrap();
345
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
346
        let m = mount(&[]);
347
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i" };
348
        let full = |p: &Path| {
349
            let f = std::fs::File::create(p).unwrap();
350
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
351
        };
352
        full(&work.path().join("a.ext4"));
353
        // Over a limit of nothing: kept, then dropped at once.
354
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap();
355
        assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none());
356
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
357
        full(&work.path().join("c.ext4"));
358
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap();
359
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
360
    }
361
}