feat(cache): unchanged sources keep their times; claims list the caches held (closes #52, refs #51) 82cf6e91d398
Beside each kept disk the pool keeps its sources: each workspace file's content hash and mtime. A job taking the disk gives every unchanged file its old time back before the workspace is imaged, so cargo rebuilds only the crates whose sources changed; a changed file keeps its checkout's time and is always rebuilt. Each claim now lists the caches the runner holds (flow_core::api::cache_slot), for the hub to place jobs by.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 15:09 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
82cf6e91d3981c447abc4c190838337b7ef36978b04e0ede467b4e204311a462- Depends on
- 3007d8f011fa 89171fca079c a083cc802892 a72abeeedd78 b14aeaae683a c3db750ed96e d60aac6c3f8a e0918b887850 ea56c56eaf90 fc10d9e80383
- Depended on by
- f201e1bc5bcd 28cc603f737d 077a65242359 2594278f49b2 ed4aef8b70ca 1e6b9fb1e73f de1d7926185f
- 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
- 34 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **The build cache pays off (#51, #52, spec §5.4).** A cached disk keeps the content hashes and times of the sources it was built from, and the next job gives every unchanged file its old time back, so cargo rebuilds only the crates that changed instead of every path crate. Claims list the caches a runner holds, and the hub sends a job to the runner holding its cache, as it does for images. |
|
| 8 | + | ||
| 7 | 9 | - **Restarts cost no job (#49, #50, spec §11.5.1).** A job's uploads and declines wait out a hub that is away for up to three minutes (a deploy restarting it), through refused connections as well as server errors, and log lines it could not send go with the next batch instead of being dropped. A runner told to stop drains: it claims nothing more, finishes and reports the jobs it holds, then exits; a second signal stops it at once. With `KillMode=mixed` and a `TimeoutStopSec` longer than the longest job in its unit, deploying a runner no longer has to wait for it to be idle. |
|
| 8 | 10 | ||
| 9 | 11 | - **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). |
|
| … | 30 unchanged lines |
modified crates/flow-core/src/api.rs +20 −0
| … | 51 unchanged lines |
||
| 52 | 52 | /// §8.1). Absent: it is not in a swarm. |
|
| 53 | 53 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 54 | 54 | pub swarm: Option<SwarmAddr>, |
|
| 55 | + | /// The build caches this runner holds a disk of (oxygenflow#51), each |
|
| 56 | + | /// [`cache_slot`] of a repository and job, so the hub can send a job |
|
| 57 | + | /// where its cache is. Absent: an older runner. |
|
| 58 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 59 | + | pub caches: Option<Vec<String>>, |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | /// A name safe as one path segment: kept readable, with a short hash so |
|
| 63 | + | /// two names that clean up alike stay apart. The build cache's pool is laid |
|
| 64 | + | /// out by these (oxygenflow#28). |
|
| 65 | + | pub fn segment(s: &str) -> String { |
|
| 66 | + | let clean: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '_' }).take(48).collect(); |
|
| 67 | + | format!("{clean}-{}", &blake3::hash(s.as_bytes()).to_hex()[..8]) |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | /// Where a repository's job keeps its build caches, as runners report |
|
| 71 | + | /// them in [`ClaimRequest::caches`] (oxygenflow#51): `repo/job`, each part |
|
| 72 | + | /// a [`segment`]. |
|
| 73 | + | pub fn cache_slot(repo: &str, job: &str) -> String { |
|
| 74 | + | format!("{}/{}", segment(repo), segment(job)) |
|
| 55 | 75 | } |
|
| 56 | 76 | ||
| 57 | 77 | /// A runner's place in the image swarm: the address peers reach it on, |
|
| … | 281 unchanged lines |
modified crates/flow/src/cache.rs +88 −28
| … | 16 unchanged lines |
||
| 17 | 17 | //! job's is thrown away, so nothing it wrote reaches a later job. The |
|
| 18 | 18 | //! receipt names what the job started from. The pool is kept under its |
|
| 19 | 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. |
|
| 20 | 27 | ||
| 21 | 28 | use anyhow::{Context, Result}; |
|
| 22 | 29 | use flow_core::pipeline::Mount; |
|
| … | 3 unchanged lines |
||
| 26 | 33 | ||
| 27 | 34 | /// The pool's default size limit. |
|
| 28 | 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 | + | } |
|
| 29 | 56 | ||
| 30 | 57 | /// Where a job's cached disks come from and go back to. |
|
| 31 | 58 | pub struct Pool { |
|
| … | 9 unchanged lines |
||
| 41 | 68 | pub image: &'a str, |
|
| 42 | 69 | } |
|
| 43 | 70 | ||
| 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 | - | } |
|
| 71 | + | use flow_core::api::segment; |
|
| 50 | 72 | ||
| 51 | 73 | impl Pool { |
|
| 52 | 74 | pub fn new(home_root: &Path, gb: Option<u64>) -> Pool { |
|
| … | 23 unchanged lines |
||
| 76 | 98 | ||
| 77 | 99 | /// Start `dest` from the pool for mount `m`, a disk of `m.size_mib`. |
|
| 78 | 100 | /// 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> { |
|
| 101 | + | pub fn take(&self, ctx: &Ctx, m: &Mount, key: &str, dest: &Path) -> Option<Taken> { |
|
| 80 | 102 | let name = m.cache.as_deref()?; |
|
| 81 | 103 | let dir = self.cache_dir(ctx, name); |
|
| 82 | 104 | let size = m.size_mib as u64 * (1 << 20); |
|
| 83 | 105 | let fits = |p: &Path| std::fs::metadata(p).is_ok_and(|md| md.len() == size); |
|
| 84 | 106 | let mine = dir.join(segment(ctx.bookmark)); |
|
| 85 | 107 | 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")); |
|
| 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 | + | } |
|
| 88 | 115 | } |
|
| 89 | 116 | if let Some(p) = newest(&mine, |p| fits(p)) { |
|
| 117 | + | let sources = read_sources(&p); |
|
| 90 | 118 | if std::fs::rename(&p, dest).is_ok() { |
|
| 91 | - | return Some(format!("cache {name}: this bookmark's, an older key")); |
|
| 119 | + | let _ = std::fs::remove_file(sources_of(&p)); |
|
| 120 | + | return Some(Taken { how: format!("cache {name}: this bookmark's, an older key"), sources }); |
|
| 92 | 121 | } |
|
| 93 | 122 | } |
|
| 94 | 123 | let others: Vec<PathBuf> = |
|
| … | 8 unchanged lines |
||
| 103 | 132 | if let Some((p, which)) = pick { |
|
| 104 | 133 | let from = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); |
|
| 105 | 134 | if sparse_copy(&p, dest).is_ok() { |
|
| 106 | - | return Some(format!("cache {name}: copied from another bookmark's ({from}), {which}")); |
|
| 135 | + | return Some(Taken { |
|
| 136 | + | how: format!("cache {name}: copied from another bookmark's ({from}), {which}"), |
|
| 137 | + | sources: read_sources(&p), |
|
| 138 | + | }); |
|
| 107 | 139 | } |
|
| 108 | 140 | } |
|
| 109 | 141 | None |
|
| … | 1 unchanged lines |
||
| 111 | 143 | ||
| 112 | 144 | /// Keep a passing job's disk for mount `m` under its key, then keep |
|
| 113 | 145 | /// the pool under its limit. |
|
| 114 | - | pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path) -> Result<()> { |
|
| 146 | + | pub fn keep(&self, ctx: &Ctx, m: &Mount, key: &str, disk: &Path, sources: &Sources) -> Result<()> { |
|
| 115 | 147 | let Some(name) = m.cache.as_deref() else { return Ok(()) }; |
|
| 116 | 148 | let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark)); |
|
| 117 | 149 | std::fs::create_dir_all(&mine)?; |
|
| … | 3 unchanged lines |
||
| 121 | 153 | } |
|
| 122 | 154 | // Moved disks keep their old time: mark it used now. |
|
| 123 | 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))?; |
|
| 124 | 159 | // One disk per key is enough; older keys of this bookmark go once |
|
| 125 | 160 | // the pool is over its limit. |
|
| 126 | 161 | self.evict(); |
|
| 127 | 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). |
|
| 168 | + | pub fn slots(&self) -> Vec<String> { |
|
| 169 | + | let mut out = Vec::new(); |
|
| 170 | + | for repo in std::fs::read_dir(&self.root).into_iter().flatten().flatten() { |
|
| 171 | + | for job in std::fs::read_dir(repo.path()).into_iter().flatten().flatten() { |
|
| 172 | + | let mut any = false; |
|
| 173 | + | walk(&job.path(), &mut |p| any |= p.extension().is_some_and(|e| e == "ext4")); |
|
| 174 | + | if any { |
|
| 175 | + | out.push(format!("{}/{}", repo.file_name().to_string_lossy(), job.file_name().to_string_lossy())); |
|
| 176 | + | } |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | out.sort(); |
|
| 180 | + | out |
|
| 128 | 181 | } |
|
| 129 | 182 | ||
| 130 | 183 | /// Drop the disks used longest ago until the pool's blocks fit. |
|
| … | 14 unchanged lines |
||
| 145 | 198 | break; |
|
| 146 | 199 | } |
|
| 147 | 200 | if std::fs::remove_file(&p).is_ok() { |
|
| 201 | + | let _ = std::fs::remove_file(sources_of(&p)); |
|
| 148 | 202 | total -= bytes; |
|
| 149 | 203 | freed += bytes; |
|
| 150 | 204 | } |
|
| … | 72 unchanged lines |
||
| 223 | 277 | let main = Ctx { repo: "andy/hub", bookmark: "main", job: "test", image: "img" }; |
|
| 224 | 278 | let key = Pool::key(&main, &m, |_| Some("lock-1".into())); |
|
| 225 | 279 | // Nothing yet: the caller makes an empty disk. |
|
| 226 | - | assert_eq!(pool.take(&main, &m, &key, &work.path().join("a.ext4")), None); |
|
| 280 | + | assert!(pool.take(&main, &m, &key, &work.path().join("a.ext4")).is_none()); |
|
| 227 | 281 | // A passing job's disk is kept and moved into the next job's place. |
|
| 228 | 282 | disk(&work.path().join("a.ext4"), b"MAIN"); |
|
| 229 | - | pool.keep(&main, &m, &key, &work.path().join("a.ext4")).unwrap(); |
|
| 283 | + | let built: Sources = [("src/lib.rs".to_string(), ("h1".to_string(), 1_000))].into(); |
|
| 284 | + | pool.keep(&main, &m, &key, &work.path().join("a.ext4"), &built).unwrap(); |
|
| 230 | 285 | 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}"); |
|
| 286 | + | let taken = pool.take(&main, &m, &key, &work.path().join("b.ext4")).unwrap(); |
|
| 287 | + | assert!(taken.how.contains("same key"), "{}", taken.how); |
|
| 288 | + | assert_eq!(taken.sources.as_ref(), Some(&built), "its sources come with it (#52)"); |
|
| 233 | 289 | assert_eq!(first(&work.path().join("b.ext4")), b"MAIN"); |
|
| 234 | - | pool.keep(&main, &m, &key, &work.path().join("b.ext4")).unwrap(); |
|
| 290 | + | pool.keep(&main, &m, &key, &work.path().join("b.ext4"), &built).unwrap(); |
|
| 235 | 291 | // A changed key takes the bookmark's newest disk all the same. |
|
| 236 | 292 | let key2 = Pool::key(&main, &m, |_| Some("lock-2".into())); |
|
| 237 | 293 | 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(); |
|
| 294 | + | let taken = pool.take(&main, &m, &key2, &work.path().join("c.ext4")).unwrap(); |
|
| 295 | + | assert!(taken.how.contains("older key"), "{}", taken.how); |
|
| 296 | + | assert_eq!(taken.sources.as_ref(), Some(&built)); |
|
| 297 | + | pool.keep(&main, &m, &key2, &work.path().join("c.ext4"), &built).unwrap(); |
|
| 298 | + | assert_eq!(pool.slots(), vec![flow_core::api::cache_slot("andy/hub", "test")], "what the claim reports (#51)"); |
|
| 241 | 299 | // Another bookmark copies it, leaving main's in place. |
|
| 242 | 300 | 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}"); |
|
| 301 | + | let taken = pool.take(&feature, &m, &key2, &work.path().join("d.ext4")).unwrap(); |
|
| 302 | + | assert!(taken.how.contains("copied from another bookmark"), "{}", taken.how); |
|
| 303 | + | assert_eq!(taken.sources.as_ref(), Some(&built), "a copy reads them and leaves them"); |
|
| 245 | 304 | 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"); |
|
| 305 | + | let taken = pool.take(&main, &m, &key2, &work.path().join("e.ext4")).unwrap(); |
|
| 306 | + | assert!(taken.how.contains("this bookmark's") && taken.sources.is_some(), "main's is still there, with its sources"); |
|
| 247 | 307 | // Another job or a different size never shares it. |
|
| 248 | 308 | let other_job = Ctx { job: "build", ..main }; |
|
| 249 | - | assert_eq!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")), None); |
|
| 309 | + | assert!(pool.take(&other_job, &m, &key2, &work.path().join("f.ext4")).is_none()); |
|
| 250 | 310 | let mut bigger = mount(&["Cargo.lock"]); |
|
| 251 | 311 | bigger.size_mib = 2; |
|
| 252 | - | assert_eq!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")), None); |
|
| 312 | + | assert!(pool.take(&feature, &bigger, &key2, &work.path().join("g.ext4")).is_none()); |
|
| 253 | 313 | } |
|
| 254 | 314 | ||
| 255 | 315 | #[test] |
|
| … | 20 unchanged lines |
||
| 276 | 336 | }; |
|
| 277 | 337 | full(&work.path().join("a.ext4")); |
|
| 278 | 338 | // 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); |
|
| 339 | + | pool.keep(&ctx, &m, "k1", &work.path().join("a.ext4"), &Sources::new()).unwrap(); |
|
| 340 | + | assert!(pool.take(&ctx, &m, "k1", &work.path().join("b.ext4")).is_none()); |
|
| 281 | 341 | let roomy = Pool { root: home.path().join("cache"), limit: 1 << 30 }; |
|
| 282 | 342 | full(&work.path().join("c.ext4")); |
|
| 283 | - | roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4")).unwrap(); |
|
| 343 | + | roomy.keep(&ctx, &m, "k2", &work.path().join("c.ext4"), &Sources::new()).unwrap(); |
|
| 284 | 344 | assert!(roomy.take(&ctx, &m, "k2", &work.path().join("d.ext4")).is_some(), "under the limit it stays"); |
|
| 285 | 345 | } |
|
| 286 | 346 | } |
modified crates/flow/src/runner.rs +38 −18
| … | 560 unchanged lines |
||
| 561 | 561 | fetches_images: true, |
|
| 562 | 562 | facts: Some(crate::facts::now(home, Some(speed))), |
|
| 563 | 563 | swarm: swarm.as_ref().map(|s| s.addr.clone()), |
|
| 564 | + | caches: Some(crate::cache::Pool::new(&home.root, cfg.cache_gb).slots()), |
|
| 564 | 565 | }; |
|
| 565 | 566 | match hub.claim(&req) { |
|
| 566 | 567 | Ok(Some(claim)) => { |
|
| … | 237 unchanged lines |
||
| 804 | 805 | let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
|
| 805 | 806 | // The syscall tier runs on the tree itself; a microVM gets an image. |
|
| 806 | 807 | let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall; |
|
| 808 | + | let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
|
| 809 | + | // Disk mounts that name a cache start from a kept disk (#28); the |
|
| 810 | + | // microVM takes a disk already in place as it is. Taken before the |
|
| 811 | + | // workspace is imaged: the sources the disk was built from give |
|
| 812 | + | // unchanged files their old times back (#52). |
|
| 813 | + | let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb); |
|
| 814 | + | let cache_ctx = |
|
| 815 | + | crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs }; |
|
| 816 | + | let mut cached: Vec<(usize, String, String)> = Vec::new(); |
|
| 817 | + | let mut built_from: Option<crate::cache::Sources> = None; |
|
| 818 | + | if !syscall_tier { |
|
| 819 | + | for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() { |
|
| 820 | + | let Some(name) = &m.cache else { continue }; |
|
| 821 | + | let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h))); |
|
| 822 | + | let how = match pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4"))) { |
|
| 823 | + | Some(t) => { |
|
| 824 | + | if built_from.is_none() { |
|
| 825 | + | built_from = t.sources; |
|
| 826 | + | } |
|
| 827 | + | t.how |
|
| 828 | + | } |
|
| 829 | + | None => format!("cache {name}: none kept yet, an empty disk"), |
|
| 830 | + | }; |
|
| 831 | + | note(&mut sink, format!("{} ({})", how, m.target)); |
|
| 832 | + | cached.push((i, key, how)); |
|
| 833 | + | } |
|
| 834 | + | } |
|
| 835 | + | let sources = if cached.is_empty() { |
|
| 836 | + | crate::cache::Sources::new() |
|
| 837 | + | } else { |
|
| 838 | + | let (sources, restored) = workspace::restore_times(&scratch.join("tree"), &files, built_from.as_ref()); |
|
| 839 | + | if built_from.is_some() { |
|
| 840 | + | note(&mut sink, format!("cache: {restored} of {} files unchanged since the cached build, their times kept", sources.len())); |
|
| 841 | + | } |
|
| 842 | + | sources |
|
| 843 | + | }; |
|
| 807 | 844 | if !syscall_tier { |
|
| 808 | 845 | workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
|
| 809 | 846 | std::fs::remove_dir_all(scratch.join("tree"))?; |
|
| … | 14 unchanged lines |
||
| 824 | 861 | &claim.sandbox.rootfs[..12] |
|
| 825 | 862 | ), |
|
| 826 | 863 | ); |
|
| 827 | - | let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env()); |
|
| 828 | 864 | let timeout = Duration::from_secs(claim.timeout_s); |
|
| 829 | - | // Disk mounts that name a cache start from a kept disk (#28); the |
|
| 830 | - | // microVM takes a disk already in place as it is. |
|
| 831 | - | let pool = crate::cache::Pool::new(&home.root, cfg.cache_gb); |
|
| 832 | - | let cache_ctx = |
|
| 833 | - | crate::cache::Ctx { repo: &claim.repo, bookmark: &claim.bookmark, job: &claim.job.name, image: &claim.sandbox.rootfs }; |
|
| 834 | - | let mut cached: Vec<(usize, String, String)> = Vec::new(); |
|
| 835 | - | if !syscall_tier { |
|
| 836 | - | for (i, m) in job.mounts.iter().filter(|m| m.source == flow_core::pipeline::MountSource::Disk).enumerate() { |
|
| 837 | - | let Some(name) = &m.cache else { continue }; |
|
| 838 | - | let key = crate::cache::Pool::key(&cache_ctx, m, |p| files.iter().find(|(f, _)| f == p).map(|(_, h)| hex::encode(h))); |
|
| 839 | - | let how = pool.take(&cache_ctx, m, &key, &scratch.join(format!("mount-{i}.ext4"))); |
|
| 840 | - | let how = how.unwrap_or_else(|| format!("cache {name}: none kept yet, an empty disk")); |
|
| 841 | - | note(&mut sink, format!("{} ({})", how, m.target)); |
|
| 842 | - | cached.push((i, key, how)); |
|
| 843 | - | } |
|
| 844 | - | } |
|
| 845 | 865 | let mut outcome = if syscall_tier { |
|
| 846 | 866 | let group = format!("job-{}", claim.job_id); |
|
| 847 | 867 | crate::syscall::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("tree"), &scratch, timeout, &mut sink, &group)? |
|
| … | 94 unchanged lines |
||
| 942 | 962 | // so nothing it wrote reaches a later job. |
|
| 943 | 963 | if passed && output_error.is_none() { |
|
| 944 | 964 | for (i, key, _) in &cached { |
|
| 945 | - | if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4"))) { |
|
| 965 | + | if let Err(e) = pool.keep(&cache_ctx, disk_mounts[*i], key, &scratch.join(format!("mount-{i}.ext4")), &sources) { |
|
| 946 | 966 | note(&mut sink, format!("cache: not kept: {e:#}")); |
|
| 947 | 967 | } |
|
| 948 | 968 | } |
|
| … | 452 unchanged lines |
modified crates/flow/src/workspace.rs +73 −0
| … | 115 unchanged lines |
||
| 116 | 116 | Ok(()) |
|
| 117 | 117 | } |
|
| 118 | 118 | ||
| 119 | + | /// Give the files of `dir` whose content a cached disk was built from |
|
| 120 | + | /// the mtime they had then (oxygenflow#52), and describe this checkout's |
|
| 121 | + | /// sources for the disk to keep: unchanged files are fresh to a build |
|
| 122 | + | /// tool that judges by time, changed ones newer than anything it built. |
|
| 123 | + | /// Whole seconds, as the workspace image keeps them. Returns the sources |
|
| 124 | + | /// and how many files got their time back. |
|
| 125 | + | pub fn restore_times(dir: &Path, files: &Files, before: Option<&crate::cache::Sources>) -> (crate::cache::Sources, usize) { |
|
| 126 | + | let mut out = crate::cache::Sources::new(); |
|
| 127 | + | let mut restored = 0; |
|
| 128 | + | for (path, hash) in files { |
|
| 129 | + | let p = dir.join(path); |
|
| 130 | + | let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; |
|
| 131 | + | if !md.is_file() { |
|
| 132 | + | continue; |
|
| 133 | + | } |
|
| 134 | + | let hex = hex::encode(hash); |
|
| 135 | + | let kept = before.and_then(|b| b.get(path)).filter(|(h, _)| *h == hex).map(|(_, t)| *t); |
|
| 136 | + | let t = match kept { |
|
| 137 | + | Some(t) => { |
|
| 138 | + | let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.max(0) as u64); |
|
| 139 | + | if std::fs::File::options().write(true).open(&p).and_then(|f| f.set_modified(at)).is_err() { |
|
| 140 | + | continue; |
|
| 141 | + | } |
|
| 142 | + | restored += 1; |
|
| 143 | + | t |
|
| 144 | + | } |
|
| 145 | + | None => md.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64).unwrap_or(0), |
|
| 146 | + | }; |
|
| 147 | + | out.insert(path.clone(), (hex, t)); |
|
| 148 | + | } |
|
| 149 | + | (out, restored) |
|
| 150 | + | } |
|
| 151 | + | ||
| 119 | 152 | /// An empty ext4 filesystem of `mib` MiB at `out`, owned by this user like |
|
| 120 | 153 | /// the workspace. mkfs.ext4 creates the file sparse, so only what the job |
|
| 121 | 154 | /// writes takes space on the runner. |
|
| … | 35 unchanged lines |
||
| 157 | 190 | if let Ok(entries) = std::fs::read_dir(dir) { |
|
| 158 | 191 | for e in entries.flatten() { |
|
| 159 | 192 | open_up(&e.path()); |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | #[cfg(test)] |
|
| 198 | + | mod tests { |
|
| 199 | + | use super::*; |
|
| 200 | + | use std::time::UNIX_EPOCH; |
|
| 201 | + | ||
| 202 | + | fn secs(p: &Path) -> i64 { |
|
| 203 | + | std::fs::metadata(p).unwrap().modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// oxygenflow#52: an unchanged file gets the time it had when the cached |
|
| 207 | + | /// disk was built; a changed or new one keeps its checkout's, newer. |
|
| 208 | + | #[test] |
|
| 209 | + | fn unchanged_sources_get_their_cached_times_back() { |
|
| 210 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 211 | + | for (name, text) in [("same.rs", "a"), ("changed.rs", "b2"), ("new.rs", "c")] { |
|
| 212 | + | std::fs::write(dir.path().join(name), text).unwrap(); |
|
| 160 | 213 | } |
|
| 214 | + | std::os::unix::fs::symlink("same.rs", dir.path().join("link.rs")).unwrap(); |
|
| 215 | + | let h = |t: &str| *blake3::hash(t.as_bytes()).as_bytes(); |
|
| 216 | + | let files: Files = |
|
| 217 | + | vec![("same.rs".into(), h("a")), ("changed.rs".into(), h("b2")), ("new.rs".into(), h("c")), ("link.rs".into(), h("same.rs"))]; |
|
| 218 | + | let now = secs(&dir.path().join("same.rs")); |
|
| 219 | + | let before: crate::cache::Sources = |
|
| 220 | + | [("same.rs".to_string(), (hex::encode(h("a")), 1_000_000)), ("changed.rs".to_string(), (hex::encode(h("b1")), 1_000_000))] |
|
| 221 | + | .into(); |
|
| 222 | + | ||
| 223 | + | let (sources, restored) = restore_times(dir.path(), &files, Some(&before)); |
|
| 224 | + | assert_eq!(restored, 1); |
|
| 225 | + | assert_eq!(secs(&dir.path().join("same.rs")), 1_000_000, "unchanged: fresh to cargo"); |
|
| 226 | + | assert!(secs(&dir.path().join("changed.rs")) >= now, "changed: newer than the cached build"); |
|
| 227 | + | assert_eq!(sources["same.rs"], (hex::encode(h("a")), 1_000_000)); |
|
| 228 | + | assert_eq!(sources["changed.rs"].0, hex::encode(h("b2")), "kept with its new content"); |
|
| 229 | + | assert!(sources.contains_key("new.rs") && !sources.contains_key("link.rs"), "files only"); |
|
| 230 | + | ||
| 231 | + | // Without a cached build nothing moves, and the sources are listed. |
|
| 232 | + | let (again, restored) = restore_times(dir.path(), &files, None); |
|
| 233 | + | assert_eq!((restored, again.len()), (0, 3)); |
|
| 161 | 234 | } |
|
| 162 | 235 | } |
modified docs/spec.md +15 −0
| … | 301 unchanged lines |
||
| 302 | 302 | hash differs from a cold build's. |
|
| 303 | 303 | - **Bounded.** The pool keeps under `cache_gb` in the runner's config |
|
| 304 | 304 | (default 64 GiB) by dropping the disks used longest ago. |
|
| 305 | + | - **Unchanged sources keep their times** (oxygenflow#52). Beside each kept |
|
| 306 | + | disk the pool keeps its sources: each workspace file's content hash and |
|
| 307 | + | the mtime it had, in whole seconds (what the workspace image keeps). A |
|
| 308 | + | job that takes the disk gives each file whose content is unchanged that |
|
| 309 | + | mtime back before the workspace is imaged; a changed or new file keeps |
|
| 310 | + | the time of its checkout, newer than anything on the disk. A build tool |
|
| 311 | + | that judges freshness by time, such as cargo, then rebuilds exactly the |
|
| 312 | + | crates whose sources changed, where a fresh checkout would make every |
|
| 313 | + | path crate look changed. The log says how many files kept their times. |
|
| 314 | + | - **Jobs go where their cache is** (oxygenflow#51). Each claim lists the |
|
| 315 | + | caches the runner holds a disk of (`caches`, each |
|
| 316 | + | `flow_core::api::cache_slot(repo, job)`), and the hub ranks a runner |
|
| 317 | + | holding a job's cache above one holding its image (§11.4): a job another |
|
| 318 | + | free runner holds the cache for is held for it for up to 15 seconds, and |
|
| 319 | + | `placed` says "holds its build cache". |
|
| 305 | 320 | - The syscall tier keeps its disks empty for now. |
|
| 306 | 321 | - A cache is a speed-up, never a source of truth: jobs must build |
|
| 307 | 322 | correctly from an empty disk, which a key change or an eviction gives |
|
| … | 318 unchanged lines |