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 |