OXYGENHUB

A build cache: disk mounts that carry a build forward d60aac6c3f8a

An ephemeral://disk mount may name a cache and the key files it depends
on (cache "target", key "Cargo.lock"). The runner keeps passing jobs'
disks in a pool by repository, job, cache and bookmark, keyed by the image,
the job, the cache name and the key files' content, and starts later jobs
from one: the same bookmark's (moved), else another bookmark's (copied, so
a proposal never takes the default bookmark's), else an empty disk. Failing
jobs' disks are never kept, the receipt's environment names what a disk
started from, and the pool stays under cache_gb (default 64 GiB).

refs #28
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 12:02 UTC
Signature
✔ signed by Andy McCall
Hash
d60aac6c3f8a202bee8d9a980d2cdb28fbf97f4940306452410a8087cbb10697
Depends on
280ef951d193 47c1d64dec8a 6d53fb7d00ec 8f211e20bea5 a083cc802892 a67b97e9a717 b14aeaae683a c3db750ed96e e0918b887850 ea56c56eaf90 ecce3719da80 fc10d9e80383
Depended on by
1215f05ff8d0 3554afefb6aa 5cf1e8aefbb6 28cc603f737d 077a65242359 2594278f49b2 f4e51e63582d e20897216221 46fe2c815cc3 ea81796bb757 1e6b9fb1e73f 82cf6e91d398 89171fca079c
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
27 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/flow/src/cache.rs +286 −0
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 +
use anyhow::{Context, Result};
22 +
use flow_core::pipeline::Mount;
23 +
use std::os::unix::fs::MetadataExt;
24 +
use std::path::{Path, PathBuf};
25 +
use std::time::SystemTime;
26 +
27 +
/// The pool's default size limit.
28 +
pub const DEFAULT_GB: u64 = 64;
29 +
30 +
/// Where a job's cached disks come from and go back to.
31 +
pub struct Pool {
32 +
    root: PathBuf,
33 +
    limit: u64,
34 +
}
35 +
36 +
/// Which job: the pool's coordinates other than the key.
37 +
pub struct Ctx<'a> {
38 +
    pub repo: &'a str,
39 +
    pub bookmark: &'a str,
40 +
    pub job: &'a str,
41 +
    pub image: &'a str,
42 +
}
43 +
44 +
/// A name safe as one path segment: kept readable, with a short hash so
45 +
/// two names that clean up alike stay apart.
46 +
fn segment(s: &str) -> String {
47 +
    let clean: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '_' }).take(48).collect();
48 +
    format!("{clean}-{}", &blake3::hash(s.as_bytes()).to_hex()[..8])
49 +
}
50 +
51 +
impl Pool {
52 +
    pub fn new(home_root: &Path, gb: Option<u64>) -> Pool {
53 +
        Pool { root: home_root.join("cache"), limit: gb.unwrap_or(DEFAULT_GB).saturating_mul(1 << 30) }
54 +
    }
55 +
56 +
    fn cache_dir(&self, ctx: &Ctx, name: &str) -> PathBuf {
57 +
        self.root.join(segment(ctx.repo)).join(segment(ctx.job)).join(segment(name))
58 +
    }
59 +
60 +
    /// A mount's key: the image, job, cache name and its key files'
61 +
    /// content hashes (`hash_of` gives a file's, `None` when absent).
62 +
    pub fn key(ctx: &Ctx, m: &Mount, hash_of: impl Fn(&str) -> Option<String>) -> String {
63 +
        let mut h = blake3::Hasher::new();
64 +
        for part in [ctx.image, ctx.job, m.cache.as_deref().unwrap_or("")] {
65 +
            h.update(part.as_bytes());
66 +
            h.update(b"\n");
67 +
        }
68 +
        for k in &m.key {
69 +
            h.update(k.as_bytes());
70 +
            h.update(b"=");
71 +
            h.update(hash_of(k).unwrap_or_else(|| "absent".into()).as_bytes());
72 +
            h.update(b"\n");
73 +
        }
74 +
        h.finalize().to_hex()[..32].to_string()
75 +
    }
76 +
77 +
    /// Start `dest` from the pool for mount `m`, a disk of `m.size_mib`.
78 +
    /// What it started from, or `None` (the caller makes an empty disk).
79 +
    pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<String> {
80 +
        let name = m.cache.as_deref()?;
81 +
        let dir = self.cache_dir(ctx, name);
82 +
        let size = m.size_mib as u64 * (1 << 20);
83 +
        let fits = |p: &Path| std::fs::metadata(p).is_ok_and(|md| md.len() == size);
84 +
        let mine = dir.join(segment(ctx.bookmark));
85 +
        let exact = mine.join(format!("{key}.ext4"));
86 +
        if fits(&exact) && std::fs::rename(&exact, dest).is_ok() {
87 +
            return Some(format!("cache {name}: this bookmark's, same key"));
88 +
        }
89 +
        if let Some(p) = newest(&mine, |p| fits(p)) {
90 +
            if std::fs::rename(&p, dest).is_ok() {
91 +
                return Some(format!("cache {name}: this bookmark's, an older key"));
92 +
            }
93 +
        }
94 +
        let others: Vec<PathBuf> =
95 +
            std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir() && *p != mine).collect();
96 +
        let pick = others
97 +
            .iter()
98 +
            .map(|d| d.join(format!("{key}.ext4")))
99 +
            .filter(|p| fits(p))
100 +
            .max_by_key(|p| modified(p))
101 +
            .map(|p| (p, "same key"))
102 +
            .or_else(|| others.iter().filter_map(|d| newest(d, |p| fits(p))).max_by_key(|p| modified(p)).map(|p| (p, "an older key")));
103 +
        if let Some((p, which)) = pick {
104 +
            let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
105 +
            if sparse_copy(&p, dest).is_ok() {
106 +
                return Some(format!("cache {name}: copied from another bookmark's ({from}), {which}"));
107 +
            }
108 +
        }
109 +
        None
110 +
    }
111 +
112 +
    /// Keep a passing job's disk for mount `m` under its key, then keep
113 +
    /// the pool under its limit.
114 +
    pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path) -> Result<()> {
115 +
        let Some(name) = m.cache.as_deref() else { return Ok(()) };
116 +
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
117 +
        std::fs::create_dir_all(&mine)?;
118 +
        let dest = mine.join(format!("{key}.ext4"));
119 +
        if std::fs::rename(disk, &dest).is_err() {
120 +
            sparse_copy(disk, &dest).with_context(|| format!("keep {}", disk.display()))?;
121 +
        }
122 +
        // Moved disks keep their old time: mark it used now.
123 +
        std::fs::File::options().write(true).open(&dest)?.set_modified(SystemTime::now())?;
124 +
        // One disk per key is enough; older keys of this bookmark go once
125 +
        // the pool is over its limit.
126 +
        self.evict();
127 +
        Ok(())
128 +
    }
129 +
130 +
    /// Drop the disks used longest ago until the pool's blocks fit.
131 +
    pub fn evict(&self) -> u64 {
132 +
        let mut disks: Vec<(SystemTime, u64, PathBuf)> = Vec::new();
133 +
        walk(&self.root, &mut |p| {
134 +
            if p.extension().is_some_and(|e| e == "ext4") {
135 +
                if let Ok(md) = std::fs::metadata(p) {
136 +
                    disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), md.blocks() * 512, p.to_path_buf()));
137 +
                }
138 +
            }
139 +
        });
140 +
        let mut total: u64 = disks.iter().map(|d| d.1).sum();
141 +
        disks.sort();
142 +
        let mut freed = 0;
143 +
        for (_, bytes, p) in disks {
144 +
            if total <= self.limit {
145 +
                break;
146 +
            }
147 +
            if std::fs::remove_file(&p).is_ok() {
148 +
                total -= bytes;
149 +
                freed += bytes;
150 +
            }
151 +
        }
152 +
        freed
153 +
    }
154 +
}
155 +
156 +
fn modified(p: &Path) -> SystemTime {
157 +
    std::fs::metadata(p).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)
158 +
}
159 +
160 +
fn newest(dir: &Path, ok: impl Fn(&Path) -> bool) -> Option<PathBuf> {
161 +
    std::fs::read_dir(dir)
162 +
        .ok()?
163 +
        .flatten()
164 +
        .map(|e| e.path())
165 +
        .filter(|p| p.extension().is_some_and(|e| e == "ext4") && ok(p))
166 +
        .max_by_key(|p| modified(p))
167 +
}
168 +
169 +
fn walk(dir: &Path, f: &mut dyn FnMut(&Path)) {
170 +
    for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
171 +
        let p = e.path();
172 +
        if p.is_dir() {
173 +
            walk(&p, f);
174 +
        } else {
175 +
            f(&p);
176 +
        }
177 +
    }
178 +
}
179 +
180 +
/// Copy a sparse disk without writing its holes.
181 +
fn sparse_copy(from: &Path, to: &Path) -> Result<()> {
182 +
    let tmp = to.with_extension("part");
183 +
    let st = std::process::Command::new("cp").arg("--sparse=always").arg(from).arg(&tmp).status().context("run cp")?;
184 +
    if !st.success() {
185 +
        let _ = std::fs::remove_file(&tmp);
186 +
        anyhow::bail!("cp {} failed", from.display());
187 +
    }
188 +
    std::fs::rename(&tmp, to)?;
189 +
    Ok(())
190 +
}
191 +
192 +
#[cfg(test)]
193 +
mod tests {
194 +
    use super::*;
195 +
    use flow_core::pipeline::MountSource;
196 +
197 +
    fn mount(key: &[&str]) -> Mount {
198 +
        Mount {
199 +
            source: MountSource::Disk,
200 +
            target: "/src/target".into(),
201 +
            size_mib: 1,
202 +
            cache: Some("target".into()),
203 +
            key: key.iter().map(|s| s.to_string()).collect(),
204 +
        }
205 +
    }
206 +
207 +
    fn disk(path: &Path, fill: &[u8]) {
208 +
        let f = std::fs::File::create(path).unwrap();
209 +
        f.set_len(1 << 20).unwrap();
210 +
        std::os::unix::fs::FileExt::write_at(&f, fill, 0).unwrap();
211 +
    }
212 +
213 +
    fn first(path: &Path) -> Vec<u8> {
214 +
        std::fs::read(path).unwrap()[..4].to_vec()
215 +
    }
216 +
217 +
    #[test]
218 +
    fn a_kept_disk_starts_the_next_job_of_its_bookmark_then_others_copy_it() {
219 +
        let home = tempfile::tempdir().unwrap();
220 +
        let work = tempfile::tempdir().unwrap();
221 +
        let pool = Pool::new(home.path(), None);
222 +
        let m = mount(&["Cargo.lock"]);
223 +
        let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img" };
224 +
        let key = Pool::key(&main, &m, |_| Some("lock-1".into()));
225 +
        // Nothing yet: the caller makes an empty disk.
226 +
        assert_eq!(pool.take(&main, &m, &key, &work.path().join("a.ext4")), None);
227 +
        // A passing job's disk is kept and moved into the next job's place.
228 +
        disk(&work.path().join("a.ext4"), b"MAIN");
229 +
        pool.keep(&main, &m, &key, &work.path().join("a.ext4")).unwrap();
230 +
        assert!(!work.path().join("a.ext4").exists(), "moved, not copied");
231 +
        let how = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
232 +
        assert!(how.contains("same key"), "{how}");
233 +
        assert_eq!(first(&work.path().join("b.ext4")), b"MAIN");
234 +
        pool.keep(&main, &m, &key, &work.path().join("b.ext4")).unwrap();
235 +
        // A changed key takes the bookmark's newest disk all the same.
236 +
        let key2 = Pool::key(&main, &m, |_| Some("lock-2".into()));
237 +
        assert_ne!(key, key2);
238 +
        let how = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
239 +
        assert!(how.contains("older key"), "{how}");
240 +
        pool.keep(&main, &m, &key2, &work.path().join("c.ext4")).unwrap();
241 +
        // Another bookmark copies it, leaving main's in place.
242 +
        let feature = Ctx { bookmark: "feature", ..main };
243 +
        let how = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap();
244 +
        assert!(how.contains("copied from another bookmark"), "{how}");
245 +
        assert_eq!(first(&work.path().join("d.ext4")), b"MAIN");
246 +
        assert!(pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap().contains("this bookmark's"), "main's is still there");
247 +
        // Another job or a different size never shares it.
248 +
        let other_job = Ctx { job: "build", ..main };
249 +
        assert_eq!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")), None);
250 +
        let mut bigger = mount(&["Cargo.lock"]);
251 +
        bigger.size_mib = 2;
252 +
        assert_eq!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")), None);
253 +
    }
254 +
255 +
    #[test]
256 +
    fn the_key_follows_the_image_the_job_and_the_key_files() {
257 +
        let ctx = Ctx { repo: "r", bookmark: "main", job: "test", image: "img-1" };
258 +
        let m = mount(&["Cargo.lock", "rust-toolchain.toml"]);
259 +
        let k = Pool::key(&ctx, &m, |p| Some(format!("{p}-v1")));
260 +
        assert_eq!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v1"))));
261 +
        assert_ne!(k, Pool::key(&ctx, &m, |p| Some(format!("{p}-v2"))), "a key file changed");
262 +
        assert_ne!(k, Pool::key(&Ctx { image: "img-2", ..ctx }, &m, |p| Some(format!("{p}-v1"))), "another image");
263 +
        assert_ne!(k, Pool::key(&ctx, &m, |_| None), "a key file missing");
264 +
    }
265 +
266 +
    #[test]
267 +
    fn the_pool_drops_the_disks_used_longest_ago() {
268 +
        let home = tempfile::tempdir().unwrap();
269 +
        let work = tempfile::tempdir().unwrap();
270 +
        let pool = Pool { root: home.path().join("cache"), limit: 0 };
271 +
        let m = mount(&[]);
272 +
        let ctx = Ctx { repo: "r", bookmark: "main", job: "j", image: "i" };
273 +
        let full = |p: &Path| {
274 +
            let f = std::fs::File::create(p).unwrap();
275 +
            std::os::unix::fs::FileExt::write_at(&f, &vec![7u8; 1 << 20], 0).unwrap();
276 +
        };
277 +
        full(&work.path().join("a.ext4"));
278 +
        // Over a limit of nothing: kept, then dropped at once.
279 +
        pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4")).unwrap();
280 +
        assert_eq!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")), None);
281 +
        let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 };
282 +
        full(&work.path().join("c.ext4"));
283 +
        roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4")).unwrap();
284 +
        assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays");
285 +
    }
286 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A build cache (#28, spec §5.4).** A disk mount may name a `cache` and the `key` files it depends on, such as `Cargo.lock`. The runner keeps passing jobs' disks and starts the next job of the same repository, job and bookmark from one, so builds carry their compiled dependencies forward; another bookmark copies it rather than taking it. Failing jobs' disks are never kept, the receipt names what a disk started from, and the pool stays under `cache_gb` (default 64 GiB).
8 +
7 9
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers talk over TLS: each runner keeps its own certificate, the hub hands its fingerprint on with the peer, and a fetcher trusts no other. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
8 10
9 11
- **A runner declines a job it cannot run (#36, spec §11.5).** Before fetching anything it checks the tier, the architecture, that the image is there and verifies, and that the sandbox's memory is free. If any check fails it hands the job back to the hub with the reason instead of running and failing it.
…
26 unchanged lines
modified crates/flow-core/src/canonical.rs +10 −1
…
67 unchanged lines
68 68
            Value::Array(
69 69
                j.mounts
70 70
                    .iter()
71 -
                    .map(|m| Value::Array(vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)]))
71 +
                    .map(|m| {
72 +
                        // A cache mount adds its name and key files; a plain
73 +
                        // one encodes as before caches existed.
74 +
                        let mut v = vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)];
75 +
                        if let Some(c) = &m.cache {
76 +
                            v.push(text(c));
77 +
                            v.push(Value::Array(m.key.iter().map(|k| text(k)).collect()));
78 +
                        }
79 +
                        Value::Array(v)
80 +
                    })
72 81
                    .collect(),
73 82
            ),
74 83
        ));
…
65 unchanged lines
modified crates/flow-core/src/pipeline.rs +41 −2
…
214 unchanged lines
215 215
}
216 216
217 217
/// An ephemeral scratch mount: empty at the start of every job and gone
218 -
/// after it, so it cannot carry anything between jobs (spec §2.2 I4).
218 +
/// after it, so it cannot carry anything between jobs (spec §2.2 I4) --
219 +
/// unless it names a `cache` (spec §5.4, #28): then a disk kept from an
220 +
/// earlier passing job may start it, and the receipt says which.
219 221
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220 222
pub struct Mount {
221 223
    pub source: MountSource,
222 224
    /// An absolute path under `/src` or `/tmp`.
223 225
    pub target: String,
224 226
    pub size_mib: u32,
227 +
    /// `cache "name"`: keep this disk after a passing job and start later
228 +
    /// jobs from it (disk mounts only).
229 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
230 +
    pub cache: Option<String>,
231 +
    /// `key "Cargo.lock" ...`: files whose content picks the cache entry.
232 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 +
    pub key: Vec<String>,
225 234
}
226 235
227 236
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
…
706 unchanged lines
934 943
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
935 944
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
936 945
    let (mut source, mut target, mut size) = (None, None, None);
946 +
    let (mut cache, mut keys) = (None, Vec::new());
937 947
    let mut seen = BTreeSet::new();
938 948
    for c in block(ctx, node) {
939 949
        let key = c.name().value();
…
46 unchanged lines
986 996
                    None => None,
987 997
                }
988 998
            }
999 +
            "cache" => {
1000 +
                cache = match ctx.one_string(c) {
1001 +
                    Some(n) if !n.is_empty() && n.len() <= 64 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) => {
1002 +
                        Some(n)
1003 +
                    }
1004 +
                    Some(n) => {
1005 +
                        ctx.err(c.span(), format!("cache name {n:?}: letters, digits, '.', '_' and '-', at most 64"));
1006 +
                        None
1007 +
                    }
1008 +
                    None => None,
1009 +
                }
1010 +
            }
1011 +
            "key" => {
1012 +
                keys = ctx.strings(c);
1013 +
                if keys.is_empty() {
1014 +
                    ctx.err(c.span(), "`key` names one or more files, as \"Cargo.lock\"");
1015 +
                }
1016 +
                for k in &keys {
1017 +
                    if k.starts_with('/') || k.split('/').any(|p| p.is_empty() || p == "." || p == "..") {
1018 +
                        ctx.err(c.span(), format!("key file {k:?} must be a path in the repository, without . or .."));
1019 +
                    }
1020 +
                }
1021 +
            }
989 1022
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
990 1023
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
991 1024
        }
…
2 unchanged lines
994 1027
        if missing && !seen.contains(name) {
995 1028
            ctx.err(node.span(), format!("a mount needs a {name}"));
996 1029
        }
1030 +
    }
1031 +
    if source == Some(MountSource::Tmpfs) && cache.is_some() {
1032 +
        ctx.err(node.span(), "only an ephemeral://disk mount can be a cache");
1033 +
    }
1034 +
    if !keys.is_empty() && cache.is_none() {
1035 +
        ctx.err(node.span(), "`key` goes with `cache`");
997 1036
    }
998 -
    Some(Mount { source: source?, target: target?, size_mib: size? })
1037 +
    Some(Mount { source: source?, target: target?, size_mib: size?, cache, key: keys })
999 1038
}
1000 1039
1001 1040
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
…
268 unchanged lines
modified crates/flow-core/src/tests.rs +26 −2
…
202 unchanged lines
203 203
    assert_eq!(
204 204
        b.mounts,
205 205
        vec![
206 -
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
207 -
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
206 +
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
207 +
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
208 208
        ],
209 209
        "sorted by target"
210 210
    );
…
9 unchanged lines
220 220
}
221 221
222 222
#[test]
223 +
fn a_disk_mount_can_be_a_cache_keyed_by_files() {
224 +
    let src = |extra: &str| {
225 +
        doc(&format!(
226 +
            "job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"8GiB\"\n{extra}    }}\n    exec \"cargo\" \"build\"\n}}\n"
227 +
        ))
228 +
    };
229 +
    let plain = Pipeline::parse(&src("")).unwrap();
230 +
    let cached = Pipeline::parse(&src("        cache \"target\"\n        key \"Cargo.lock\" \"rust-toolchain.toml\"\n")).unwrap();
231 +
    let m = &cached.jobs["b"].mounts[0];
232 +
    assert_eq!((m.cache.as_deref(), m.key.clone()), (Some("target"), vec!["Cargo.lock".to_string(), "rust-toolchain.toml".to_string()]));
233 +
    assert_ne!(cached.hash(), plain.hash(), "a cache is part of the job");
234 +
    let keyless = Pipeline::parse(&src("        cache \"target\"\n")).unwrap();
235 +
    assert_ne!(keyless.hash(), cached.hash());
236 +
    let ps = problems(&src("        cache \"no spaces\"\n"));
237 +
    assert!(ps.iter().any(|(_, m)| m.contains("cache name")), "{ps:?}");
238 +
    let ps = problems(&src("        key \"Cargo.lock\"\n"));
239 +
    assert!(ps.iter().any(|(_, m)| m.contains("goes with `cache`")), "{ps:?}");
240 +
    let ps = problems(&src("        cache \"t\"\n        key \"../x\"\n"));
241 +
    assert!(ps.iter().any(|(_, m)| m.contains("path in the repository")), "{ps:?}");
242 +
    let tmpfs = doc("job \"b\" {\n    sandbox \"alpine\"\n    mount {\n        source \"ephemeral://tmpfs\"\n        target \"/tmp/c\"\n        size \"64MiB\"\n        cache \"c\"\n    }\n    exec \"true\"\n}\n");
243 +
    assert!(problems(&tmpfs).iter().any(|(_, m)| m.contains("only an ephemeral://disk")));
244 +
}
245 +
246 +
#[test]
223 247
fn mounts_are_checked() {
224 248
    let job = |mount: &str| doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n{mount}\n    }}\n    exec \"true\"\n}}\n"));
225 249
    let has = |src: String, want: &str| {
…
355 unchanged lines
modified crates/flow/src/home.rs +3 −0
…
60 unchanged lines
61 61
    /// the runner neither serves nor asks peers.
62 62
    #[serde(default)]
63 63
    pub swarm: Option<SwarmConfig>,
64 +
    /// The build cache's size limit in GiB (#28; default 64).
65 +
    #[serde(default)]
66 +
    pub cache_gb: Option<u64>,
64 67
}
65 68
66 69
/// `[runner.swarm]` in flow.toml, or `[swarm]` in runner.toml.
…
184 unchanged lines
modified crates/flow/src/main.rs +1 −0
1 1
//! `flow`: check pipelines, run them locally, and run jobs for OxygenHub
2 2
//! (docs/spec.md, docs/m0.md).
3 3
4 +
mod cache;
4 5
mod cpio;
5 6
mod facts;
6 7
/// The job-starting code `flow-init` uses in a microVM, shared with the
…
560 unchanged lines
modified crates/flow/src/microvm.rs +7 −4
…
95 unchanged lines
96 96
    let mut disks = Vec::new();
97 97
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
98 98
        let path = scratch.join(format!("mount-{i}.ext4"));
99 -
        crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
99 +
        // A disk already in place came from the build cache (#28).
100 +
        if !path.exists() {
101 +
            crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?;
102 +
        }
100 103
        disks.push(
101 104
            serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }),
102 105
        );
…
164 unchanged lines
267 270
        .unwrap();
268 271
        assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts");
269 272
        job.mounts = vec![
270 -
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
271 -
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
272 -
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64 },
273 +
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
274 +
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
275 +
            Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64, cache: None, key: vec![] },
273 276
        ];
274 277
        let spec = guest_spec(&job);
275 278
        assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"}));
…
5 unchanged lines
modified crates/flow/src/outputs.rs +3 −3
…
218 unchanged lines
219 219
            secrets: vec![],
220 220
            identities: vec![],
221 221
            mounts: vec![
222 -
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 },
223 -
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 },
224 -
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 },
222 +
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
223 +
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
224 +
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
225 225
            ],
226 226
            outputs: vec![],
227 227
            runs_on: vec![],
…
86 unchanged lines
modified crates/flow/src/runner.rs +35 −2
…
732 unchanged lines
733 733
        );
734 734
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
735 735
        let timeout = Duration::from_secs(claim.timeout_s);
736 -
        let outcome = if syscall_tier {
736 +
        // Disk mounts that name a cache start from a kept disk (#28); the
737 +
        // microVM takes a disk already in place as it is.
738 +
        let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb);
739 +
        let cache_ctx =
740 +
            crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs };
741 +
        let mut cached: Vec<(usize, String, String)> = Vec::new();
742 +
        if !syscall_tier {
743 +
            for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() {
744 +
                let Some(name) = &m.cache else { continue };
745 +
                let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h)));
746 +
                let how = pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4")));
747 +
                let how = how.unwrap_or_else(|| format!("cache {name}: none kept yet, an empty disk"));
748 +
                note(&mut sink, format!("{} ({})", how, m.target));
749 +
                cached.push((i, key, how));
750 +
            }
751 +
        }
752 +
        let mut outcome = if syscall_tier {
737 753
            let group = format!("job-{}", claim.job_id);
738 754
            crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)?
739 755
        } else {
…
1 unchanged lines
741 757
        };
742 758
        if outcome.cancelled {
743 759
            return Ok(cancelled());
760 +
        }
761 +
        // The receipt says what a cached disk started from.
762 +
        let disk_mounts: Vec<&flow_core::pipeline::Mount> =
763 +
            job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).collect();
764 +
        for (i, key, how) in &cached {
765 +
            outcome.environment.parts.push((format!("cache {}", disk_mounts[*i].target), format!("{key}: {how}")));
744 766
        }
745 767
        // A passing job's artifacts, read from its disks and uploaded; a
746 768
        // declared artifact that cannot be kept fails the job. A failing
…
74 unchanged lines
821 843
            Some(r) => Some(hex::encode(sign_receipt(&repo, &heads, cfg, key, claim, r)?.encode())),
822 844
            None => None,
823 845
        };
846 +
        // Measured before a cached disk leaves the scratch directory.
847 +
        let peak_disk_mib = crate::facts::allocated_mib(&scratch);
848 +
        // A passing job's cached disks are kept; a failing job's are not,
849 +
        // so nothing it wrote reaches a later job.
850 +
        if passed && output_error.is_none() {
851 +
            for (i, key, _) in &cached {
852 +
                if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4"))) {
853 +
                    note(&mut sink, format!("cache: not kept: {e:#}"));
854 +
                }
855 +
            }
856 +
        }
824 857
        Ok(Finish {
825 858
            exit_code: outcome.exit_code,
826 859
            duration_ms: started.elapsed().as_millis() as u64,
…
2 unchanged lines
829 862
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
830 863
            // What the job took (oxygenflow#41): its disks are sparse files
831 864
            // in the scratch directory, so their blocks now are their peak.
832 -
            peak_disk_mib: crate::facts::allocated_mib(&scratch),
865 +
            peak_disk_mib,
833 866
            peak_memory_mib: outcome.peak_memory_mib,
834 867
        })
835 868
    })();
…
370 unchanged lines
modified docs/spec.md +38 −0
…
268 unchanged lines
269 269
}
270 270
```
271 271
272 +
### 5.4 The Build Cache (oxygenflow#28)
273 +
274 +
An `ephemeral://disk` mount may name a `cache`, and the files that key it:
275 +
276 +
```kdl
277 +
mount {
278 +
    source "ephemeral://disk"
279 +
    target "/src/target"
280 +
    size "24GiB"
281 +
    cache "target"
282 +
    key "Cargo.lock" "rust-toolchain.toml"
283 +
}
284 +
```
285 +
286 +
Such a disk is not always empty at the start. Each runner keeps a pool of
287 +
the disks of passing jobs, by repository, job, cache name and bookmark,
288 +
named by the **key**: the BLAKE3 of the sandbox image, the job's name, the
289 +
cache name, and each key file's content in the job's state. A job's disk
290 +
starts as, in order: its bookmark's disk with the same key (moved), its
291 +
bookmark's newest disk (moved), another bookmark's with the same key and
292 +
then another's newest (copied, so a proposal never takes the default
293 +
bookmark's), and otherwise an empty disk. A disk must be the size the
294 +
mount asks for.
295 +
296 +
- **Only passing jobs keep a disk.** When the job and its outputs pass,
297 +
  its cached disks go back into the pool under their key; otherwise they
298 +
  are discarded with the scratch directory, so nothing a failing job wrote
299 +
  reaches a later one.
300 +
- **The receipt says so.** The job's environment gains `cache TARGET` with
301 +
  the key and what the disk started from, so the receipt's environment
302 +
  hash differs from a cold build's.
303 +
- **Bounded.** The pool keeps under `cache_gb` in the runner's config
304 +
  (default 64 GiB) by dropping the disks used longest ago.
305 +
- The syscall tier keeps its disks empty for now.
306 +
- A cache is a speed-up, never a source of truth: jobs must build
307 +
  correctly from an empty disk, which a key change or an eviction gives
308 +
  them at any time.
309 +
272 310
## 6. Commutative Execution & Change Caching
273 311
274 312
In Oxygen, changes that do not share scopes (referenced atoms, placements, or
…
282 unchanged lines