OXYGENHUB

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