OXYGENHUB

Checkpoints: a handed-back job resumes (refs #77) f201e1bc5bcd

checkpoint "/src/..." on a job names directories that hold its
progress. A runner handing the job back packs them (files with their
modes and times, which cargo's fingerprints read) and keeps them with
POST /flow/jobs/{id}/checkpoint before the release; the next attempt's
claim names the blob and its runner restores it over the cache before
running the same exec. The receipt lists it among the inputs. The WASM
and syscall tiers for now. The runner's own archive format, so CI needs
no new crate.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 71b4d23d43af
When
· 2026-10-25 22:17 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
f201e1bc5bcd764e0a2712d7c2ca21378cc5fe13a2ae62366b1095d87739e83c
Depends on
1b160d372d9e 280ef951d193 28cc603f737d 46fe2c815cc3 5cf1e8aefbb6 720eb8191059 82cf6e91d398 89171fca079c a083cc802892 a67b97e9a717 bb643c252515 c3db750ed96e c4285535b03a e0918b887850 e87c1c138187 fc10d9e80383
Depended on by
3554afefb6aa 67851a868a3b
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
16 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/flow/src/checkpoint.rs +214 −0
1 +
//! Checkpoints (oxygenflow#77, docs/handover.md §7): the directories a job
2 +
//! declares with `checkpoint "/src/…"`, saved when its runner hands it back
3 +
//! and restored before the next attempt runs the same exec, so a tool that
4 +
//! skips finished work (cargo, make) carries on where it stopped.
5 +
//!
6 +
//! The format is the runner's own, so CI builds need no new crate: a magic
7 +
//! line, then entries (a kind byte, the path under /src, mode, modification
8 +
//! time and, for a file, its bytes). Files keep their times, since cargo's
9 +
//! fingerprints read them. Links are left out. Unpacking accepts only paths
10 +
//! under the job's own checkpoints, without `.` or `..`.
11 +
12 +
use anyhow::{bail, Context, Result};
13 +
use flow_core::pipeline::{Job, MountSource};
14 +
use std::path::{Path, PathBuf};
15 +
use std::time::{Duration, SystemTime, UNIX_EPOCH};
16 +
17 +
/// At most this much is saved: the hub keeps it as it keeps an artifact.
18 +
pub const MAX_BYTES: usize = 512 << 20;
19 +
20 +
const MAGIC: &[u8] = b"OXYFLOW-CHECKPOINT 1\n";
21 +
const DIR: u8 = 0;
22 +
const FILE: u8 = 1;
23 +
24 +
/// What was saved or restored.
25 +
#[derive(Debug, Default, PartialEq, Eq)]
26 +
pub struct Counts {
27 +
    pub files: usize,
28 +
    pub dirs: usize,
29 +
    pub bytes: u64,
30 +
}
31 +
32 +
/// Where checkpoint `path` (under /src) is on this host, for a job of a
33 +
/// directory tier (WASM, syscall): in a disk mount's directory when a mount
34 +
/// holds it, else in the job's tree. `None` for one under a tmpfs, which
35 +
/// the syscall tier keeps in memory.
36 +
pub fn host_dir(path: &str, job: &Job, tree: &Path, scratch: &Path, wasm: bool) -> Option<PathBuf> {
37 +
    let under = |t: &str| path == t || path.starts_with(&format!("{}/", t.trim_end_matches('/')));
38 +
    let mut disks = 0;
39 +
    let mut best: Option<(usize, PathBuf)> = None;
40 +
    for (i, m) in job.mounts.iter().enumerate() {
41 +
        let dir = match m.source {
42 +
            MountSource::Disk => {
43 +
                disks += 1;
44 +
                Some(crate::syscall::disk_dir(scratch, disks - 1))
45 +
            }
46 +
            MountSource::Tmpfs if wasm => Some(scratch.join(format!("tmpfs-{i}"))),
47 +
            MountSource::Tmpfs => None,
48 +
        };
49 +
        if under(&m.target) && best.as_ref().is_none_or(|(len, _)| m.target.len() > *len) {
50 +
            let rest = path[m.target.trim_end_matches('/').len()..].trim_start_matches('/');
51 +
            best = Some((m.target.len(), dir.map(|d| d.join(rest)).unwrap_or_default()));
52 +
        }
53 +
    }
54 +
    match best {
55 +
        Some((_, d)) if d.as_os_str().is_empty() => None,
56 +
        Some((_, d)) => Some(d),
57 +
        None => Some(tree.join(path.trim_start_matches("/src").trim_start_matches('/'))),
58 +
    }
59 +
}
60 +
61 +
/// Pack `dirs` (each a checkpoint path and where it is on this host).
62 +
pub fn pack(dirs: &[(String, PathBuf)]) -> Result<(Vec<u8>, Counts)> {
63 +
    let mut out = MAGIC.to_vec();
64 +
    let mut n = Counts::default();
65 +
    for (path, host) in dirs {
66 +
        if !host.is_dir() {
67 +
            continue;
68 +
        }
69 +
        let mut stack = vec![(host.clone(), path.trim_end_matches('/').to_string())];
70 +
        while let Some((at, name)) = stack.pop() {
71 +
            let meta = std::fs::symlink_metadata(&at)?;
72 +
            if meta.is_dir() {
73 +
                entry(&mut out, DIR, &name, &meta, &[]);
74 +
                n.dirs += 1;
75 +
                let mut children: Vec<_> = std::fs::read_dir(&at)?.collect::<std::io::Result<_>>()?;
76 +
                children.sort_by_key(|e| e.file_name());
77 +
                for c in children.into_iter().rev() {
78 +
                    let Some(child) = c.file_name().to_str().map(String::from) else { continue };
79 +
                    stack.push((c.path(), format!("{name}/{child}")));
80 +
                }
81 +
            } else if meta.is_file() {
82 +
                let bytes = std::fs::read(&at).with_context(|| format!("read {}", at.display()))?;
83 +
                n.files += 1;
84 +
                n.bytes += bytes.len() as u64;
85 +
                entry(&mut out, FILE, &name, &meta, &bytes);
86 +
            }
87 +
            if out.len() > MAX_BYTES {
88 +
                bail!("the checkpoint is over {} MiB", MAX_BYTES >> 20);
89 +
            }
90 +
        }
91 +
    }
92 +
    Ok((out, n))
93 +
}
94 +
95 +
fn entry(out: &mut Vec<u8>, kind: u8, path: &str, meta: &std::fs::Metadata, bytes: &[u8]) {
96 +
    use std::os::unix::fs::PermissionsExt;
97 +
    let since = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).unwrap_or_default();
98 +
    out.push(kind);
99 +
    out.extend((path.len() as u32).to_le_bytes());
100 +
    out.extend(path.as_bytes());
101 +
    out.extend(meta.permissions().mode().to_le_bytes());
102 +
    out.extend(since.as_secs().to_le_bytes());
103 +
    out.extend(since.subsec_nanos().to_le_bytes());
104 +
    out.extend((bytes.len() as u64).to_le_bytes());
105 +
    out.extend(bytes);
106 +
}
107 +
108 +
/// Restore a checkpoint: each entry at `place(checkpoint)` joined with the
109 +
/// rest of its path, for the job's own `checkpoints` only.
110 +
pub fn unpack(bytes: &[u8], checkpoints: &[String], place: impl Fn(&str) -> Option<PathBuf>) -> Result<Counts> {
111 +
    use std::os::unix::fs::PermissionsExt;
112 +
    let Some(mut rest) = bytes.strip_prefix(MAGIC) else { bail!("not a checkpoint") };
113 +
    let mut n = Counts::default();
114 +
    let take = |rest: &mut &[u8], k: usize| -> Result<Vec<u8>> {
115 +
        if rest.len() < k {
116 +
            bail!("the checkpoint is cut short");
117 +
        }
118 +
        let (a, b) = rest.split_at(k);
119 +
        *rest = b;
120 +
        Ok(a.to_vec())
121 +
    };
122 +
    while !rest.is_empty() {
123 +
        let kind = take(&mut rest, 1)?[0];
124 +
        let len = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap()) as usize;
125 +
        let path = String::from_utf8(take(&mut rest, len)?).context("a path that is not UTF-8")?;
126 +
        let mode = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap());
127 +
        let secs = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap());
128 +
        let nanos = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap());
129 +
        let size = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap()) as usize;
130 +
        let data = take(&mut rest, size)?;
131 +
        // Under one of the job's checkpoints, without . or .. on the way.
132 +
        let Some(root) = checkpoints.iter().map(|c| c.trim_end_matches('/')).find(|c| path == *c || path.starts_with(&format!("{c}/")))
133 +
        else {
134 +
            bail!("{path} is not under the job's checkpoints");
135 +
        };
136 +
        let below = path[root.len()..].trim_start_matches('/');
137 +
        if below.split('/').any(|p| p == "." || p == ".." || (p.is_empty() && !below.is_empty())) {
138 +
            bail!("{path}: . or .. in a checkpoint path");
139 +
        }
140 +
        let Some(base) = place(root) else { continue };
141 +
        let at = if below.is_empty() { base } else { base.join(below) };
142 +
        match kind {
143 +
            DIR => {
144 +
                std::fs::create_dir_all(&at)?;
145 +
                n.dirs += 1;
146 +
            }
147 +
            FILE => {
148 +
                if let Some(parent) = at.parent() {
149 +
                    std::fs::create_dir_all(parent)?;
150 +
                }
151 +
                // Never through a link already there.
152 +
                if std::fs::symlink_metadata(&at).is_ok_and(|m| !m.is_file()) {
153 +
                    bail!("{path}: something other than a file is in the way");
154 +
                }
155 +
                std::fs::write(&at, &data)?;
156 +
                let f = std::fs::File::options().write(true).open(&at)?;
157 +
                f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))?;
158 +
                f.set_modified(SystemTime::UNIX_EPOCH + Duration::new(secs, nanos))?;
159 +
                n.files += 1;
160 +
                n.bytes += data.len() as u64;
161 +
            }
162 +
            other => bail!("{path}: unknown entry kind {other}"),
163 +
        }
164 +
    }
165 +
    Ok(n)
166 +
}
167 +
168 +
#[cfg(test)]
169 +
mod tests {
170 +
    use super::*;
171 +
172 +
    #[test]
173 +
    fn a_checkpoint_comes_back_with_its_times_and_only_where_it_belongs() {
174 +
        let from = tempfile::tempdir().unwrap();
175 +
        let target = from.path().join("target");
176 +
        std::fs::create_dir_all(target.join("debug/deps")).unwrap();
177 +
        std::fs::write(target.join("debug/deps/libx.rlib"), b"built").unwrap();
178 +
        std::fs::write(target.join("debug/.fingerprint"), b"fp").unwrap();
179 +
        let old = SystemTime::UNIX_EPOCH + Duration::new(1_700_000_000, 123_000_000);
180 +
        std::fs::File::options().write(true).open(target.join("debug/deps/libx.rlib")).unwrap().set_modified(old).unwrap();
181 +
        let (bytes, n) = pack(&[("/src/target".into(), target.clone())]).unwrap();
182 +
        assert_eq!((n.files, n.dirs, n.bytes), (2, 3, 7));
183 +
184 +
        let to = tempfile::tempdir().unwrap();
185 +
        let place = |root: &str| (root == "/src/target").then(|| to.path().join("t"));
186 +
        let back = unpack(&bytes, &["/src/target".into()], place).unwrap();
187 +
        assert_eq!(back, n);
188 +
        let restored = to.path().join("t/debug/deps/libx.rlib");
189 +
        assert_eq!(std::fs::read(&restored).unwrap(), b"built");
190 +
        assert_eq!(std::fs::metadata(&restored).unwrap().modified().unwrap(), old, "cargo reads the times");
191 +
192 +
        // Not the job's checkpoint, or a path that climbs out: refused.
193 +
        assert!(unpack(&bytes, &["/src/other".into()], place).is_err());
194 +
        let mut evil = MAGIC.to_vec();
195 +
        let meta = std::fs::metadata(target.join("debug/.fingerprint")).unwrap();
196 +
        entry(&mut evil, FILE, "/src/target/../escape", &meta, b"x");
197 +
        assert!(unpack(&evil, &["/src/target".into()], place).is_err());
198 +
        assert!(!to.path().join("escape").exists());
199 +
        assert!(unpack(b"junk", &["/src/target".into()], place).is_err());
200 +
    }
201 +
202 +
    #[test]
203 +
    fn a_checkpoint_under_a_disk_mount_is_in_the_mount_s_directory() {
204 +
        let p = flow_core::Pipeline::parse(
205 +
            "schema-version 1\nsandbox \"w\" {\n    runtime \"wasm\"\n}\njob \"j\" {\n    sandbox \"w\"\n    mount {\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"1GiB\"\n    }\n    checkpoint \"/src/target\" \"/src/state\"\n    exec \"x.wasm\"\n}\n",
206 +
        )
207 +
        .unwrap();
208 +
        let job = &p.jobs["j"];
209 +
        assert_eq!(job.checkpoints, ["/src/state", "/src/target"]);
210 +
        let (tree, scratch) = (Path::new("/w/tree"), Path::new("/w"));
211 +
        assert_eq!(host_dir("/src/target", job, tree, scratch, true), Some(crate::syscall::disk_dir(scratch, 0)));
212 +
        assert_eq!(host_dir("/src/state", job, tree, scratch, true), Some(PathBuf::from("/w/tree/state")));
213 +
    }
214 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Checkpoints: a handed-back job resumes (#77):** `checkpoint "/src/…"` on a job names directories that hold its progress; a runner handing the job back packs and keeps them (`POST …/checkpoint`), and the next attempt's runner restores them before running the same exec, so it carries on rather than starting again. The receipt lists the checkpoint among its inputs. WASM and syscall tiers for now. Spec §11.5.2.
7 8
- **A leaving runner finishes what fits its notice (#76):** a claim carries `expected_ms` (the median of the job's recent passing runs, from OxygenHub 0.9.4); `runner::leave_within(reason, notice)` finishes and reports a job whose expected rest fits the notice and hands back the others, and any still running when it is up. `runner::leave` is the same with no notice. Spec §11.5.2.
8 9
- **OxygenFlow's own CI writes a test report and coverage (#82):** its test job runs under cargo-nextest and cargo-llvm-cov (both in the Rust image, #81), keeping a JUnit report (whether or not the tests pass) and LCOV coverage for the hub's Tests and Coverage tabs; `.config/nextest.toml` has the `ci` profile.
9 10
- **A runner that leaves hands its jobs back (#76):** `runner::leave(reason)` (the runner as a library, as the Android app runs it) stops claiming, stops the jobs it holds, sends what they logged and hands them back with `POST /api/v1/flow/jobs/{id}/release`; the hub (OxygenHub #312) queues them again ahead of new work for another runner, and hands back the jobs of a runner silent for 3 minutes the same way. Spec §11.5.2.
…
92 unchanged lines
modified crates/flow-core/src/api.rs +6 −0
…
289 unchanged lines
290 290
    /// finishes a job whose rest fits it and hands back the others.
291 291
    #[serde(default, skip_serializing_if = "Option::is_none")]
292 292
    pub expected_ms: Option<u64>,
293 +
    /// The checkpoint an earlier attempt saved before its runner handed the
294 +
    /// job back (oxygenflow#77): the blob to fetch from
295 +
    /// `/api/v1/flow/jobs/{job_id}/checkpoint` and check, then restore
296 +
    /// before the job runs.
297 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
298 +
    pub checkpoint: Option<String>,
293 299
}
294 300
295 301
/// An artifact a job takes (oxygenflow#58), as the hub hands it over.
…
90 unchanged lines
modified crates/flow-core/src/pipeline.rs +19 −0
…
209 unchanged lines
210 210
    /// (oxygenhub#244); higher levels are the hub's to give.
211 211
    #[serde(default, skip_serializing_if = "Option::is_none")]
212 212
    pub priority: Option<String>,
213 +
    /// Directories under /src that hold the job's progress (`checkpoint
214 +
    /// "/src/target"`, oxygenflow#77), sorted: saved when its runner hands
215 +
    /// it back, restored before the next attempt runs the same exec.
216 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 +
    pub checkpoints: Vec<String>,
213 218
}
214 219
215 220
impl Job {
…
1295 unchanged lines
1511 1516
        timeout_s: None,
1512 1517
        on: None,
1513 1518
        priority: None,
1519 +
        checkpoints: Vec::new(),
1514 1520
    };
1515 1521
    let mut seen = BTreeSet::new();
1516 1522
    let mut outputs_at = None;
…
5 unchanged lines
1522 1528
            && key != "secret"
1523 1529
            && key != "identity"
1524 1530
            && key != "runs-on"
1531 +
            && key != "checkpoint"
1525 1532
            && !seen.insert(key.to_string())
1526 1533
        {
1527 1534
            ctx.err(c.span(), format!("`{key}` is given twice"));
…
2 unchanged lines
1530 1537
            "sandbox" => {
1531 1538
                ctx.no_children(c);
1532 1539
                job.sandbox = ctx.one_string(c).unwrap_or_default();
1540 +
            }
1541 +
            "checkpoint" => {
1542 +
                ctx.no_children(c);
1543 +
                for p in ctx.strings(c) {
1544 +
                    if output_path_ok(&p) && p.trim_end_matches('/') != "/src" {
1545 +
                        job.checkpoints.push(p);
1546 +
                    } else {
1547 +
                        ctx.err(c.span(), format!("checkpoint {p:?} must be a directory under /src, without . or .., quotes or spaces"));
1548 +
                    }
1549 +
                }
1533 1550
            }
1534 1551
            "scope" => {
1535 1552
                ctx.no_children(c);
…
101 unchanged lines
1637 1654
    }
1638 1655
    job.depends_on.sort();
1639 1656
    job.depends_on.dedup();
1657 +
    job.checkpoints.sort();
1658 +
    job.checkpoints.dedup();
1640 1659
    // Kept even when incomplete, so its references are checked too; the
1641 1660
    // problems above already make the pipeline invalid.
1642 1661
    named.then_some(job)
…
1 unchanged lines
modified crates/flow-core/src/tests.rs +1 −0
…
445 unchanged lines
446 446
        secrets: vec![],
447 447
        inputs: vec![],
448 448
        expected_ms: Some(17_700),
449 +
        checkpoint: None,
449 450
    };
450 451
    let env = job.with_flow_env(&claim.flow_env()).env;
451 452
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
…
307 unchanged lines
modified crates/flow/src/lib.rs +1 −0
…
2 unchanged lines
3 3
//! runs the WASM tier in its own process (docs/handover.md §10).
4 4
5 5
pub mod cache;
6 +
pub mod checkpoint;
6 7
pub mod cpio;
7 8
pub mod egress;
8 9
pub mod facts;
…
15 unchanged lines
modified crates/flow/src/outputs.rs +1 −0
…
230 unchanged lines
231 231
            timeout_s: None,
232 232
            on: None,
233 233
            priority: None,
234 +
            checkpoints: Vec::new(),
234 235
        }
235 236
    }
236 237
…
81 unchanged lines
modified crates/flow/src/runner.rs +82 −0
…
182 unchanged lines
183 183
        v["blob"].as_str().map(String::from).context("the hub named no blob")
184 184
    }
185 185
186 +
    /// Keep a checkpoint of job `job` (oxygenflow#77) as its runner hands
187 +
    /// it back: the hub answers with its blob, which the next attempt's
188 +
    /// claim names.
189 +
    pub fn save_checkpoint(&self, job: i64, bytes: &[u8]) -> Result<String> {
190 +
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
191 +
        let r = retrying(&url, &PATIENT_DELAYS, true, || {
192 +
            self.agent
193 +
                .post(&url)
194 +
                .timeout(Duration::from_secs(600))
195 +
                .set("Authorization", &format!("Bearer {}", self.token))
196 +
                .set("Content-Type", "application/octet-stream")
197 +
                .send_bytes(bytes)
198 +
                .map_err(Box::new)
199 +
        })?;
200 +
        let v: serde_json::Value = r.into_json()?;
201 +
        v["blob"].as_str().map(String::from).context("the hub named no blob")
202 +
    }
203 +
204 +
    /// The checkpoint an earlier attempt of job `job` saved (oxygenflow#77),
205 +
    /// which the caller checks against the blob its claim names.
206 +
    pub fn checkpoint(&self, job: i64) -> Result<Vec<u8>> {
207 +
        let url = format!("{}/api/v1/flow/jobs/{job}/checkpoint", self.base);
208 +
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
209 +
            self.agent
210 +
                .get(&url)
211 +
                .timeout(Duration::from_secs(600))
212 +
                .set("Authorization", &format!("Bearer {}", self.token))
213 +
                .call()
214 +
                .map_err(Box::new)
215 +
        })?;
216 +
        let mut bytes = Vec::new();
217 +
        std::io::Read::read_to_end(&mut std::io::Read::take(r.into_reader(), crate::checkpoint::MAX_BYTES as u64 + 1), &mut bytes)?;
218 +
        Ok(bytes)
219 +
    }
220 +
186 221
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
187 222
    /// producing job's artifact, which the caller checks against the blob the
188 223
    /// claim names.
…
637 unchanged lines
826 861
        }
827 862
        Ok(())
828 863
    })
864 +
}
865 +
866 +
/// Pack and keep a handed-back job's checkpoints (oxygenflow#77); what
867 +
/// happened goes to its log, and a failure only means the next attempt
868 +
/// starts again.
869 +
#[allow(clippy::too_many_arguments)]
870 +
fn save_checkpoint(hub: &Hub, id: i64, job: &flow_core::Job, scratch: &Path, dir_tier: bool, wasm: bool, mut say: impl FnMut(String)) {
871 +
    if !dir_tier {
872 +
        say("checkpoint: only the WASM and syscall tiers save one yet; the next attempt starts again".into());
873 +
        return;
874 +
    }
875 +
    let dirs: Vec<(String, PathBuf)> = job
876 +
        .checkpoints
877 +
        .iter()
878 +
        .filter_map(|c| crate::checkpoint::host_dir(c, job, &scratch.join("tree"), scratch, wasm).map(|d| (c.clone(), d)))
879 +
        .collect();
880 +
    match crate::checkpoint::pack(&dirs).and_then(|(bytes, n)| Ok((hub.save_checkpoint(id, &bytes)?, n))) {
881 +
        Ok((blob, n)) => {
882 +
            say(format!("checkpoint {}: {} files, {} bytes, kept for the next attempt", &blob[..12.min(blob.len())], n.files, n.bytes))
883 +
        }
884 +
        Err(e) => say(format!("checkpoint: not kept ({e:#}); the next attempt starts again")),
885 +
    }
829 886
}
830 887
831 888
/// How many times the runner was told to stop (SIGTERM or SIGINT).
…
302 unchanged lines
1134 1191
            }
1135 1192
            sources
1136 1193
        };
1194 +
        // An earlier attempt's checkpoint (oxygenflow#77): back in place,
1195 +
        // over the cache, before the same exec runs again; the receipt
1196 +
        // lists it among the job's inputs.
1197 +
        if let Some(blob) = &claim.checkpoint {
1198 +
            if dir_tier {
1199 +
                let bytes = hub.checkpoint(claim.job_id).context("fetching the checkpoint")?;
1200 +
                if Repo::blob_ref(&bytes).to_hex() != *blob {
1201 +
                    bail!("the checkpoint fetched is not blob {}, as the claim names", &blob[..12.min(blob.len())]);
1202 +
                }
1203 +
                let place = |root: &str| crate::checkpoint::host_dir(root, &job, &scratch.join("tree"), &scratch, wasm_tier);
1204 +
                let n = crate::checkpoint::unpack(&bytes, &job.checkpoints, place)?;
1205 +
                note(
1206 +
                    &mut sink,
1207 +
                    format!("restored checkpoint {}: {} files, {} bytes, from the attempt handed back", &blob[..12], n.files, n.bytes),
1208 +
                );
1209 +
                input_blobs.push(Repo::blob_ref(&bytes).0);
1210 +
            } else {
1211 +
                note(&mut sink, "a checkpoint was saved, but only the WASM and syscall tiers restore one yet: starting again".into());
1212 +
            }
1213 +
        }
1137 1214
        if !dir_tier {
1138 1215
            workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
1139 1216
            std::fs::remove_dir_all(scratch.join("tree"))?;
…
28 unchanged lines
1168 1245
            microvm::run(home, &claim.sandbox, &job, &claim.secrets, &scratch.join("workspace.ext4"), &scratch, timeout, &mut sink)?
1169 1246
        };
1170 1247
        if outcome.cancelled {
1248 +
            // Handed back as the runner leaves: its checkpoints first, for
1249 +
            // the next attempt (oxygenflow#77).
1250 +
            if leaving().is_some() && !job.checkpoints.is_empty() {
1251 +
                save_checkpoint(hub, claim.job_id, &job, &scratch, dir_tier, wasm_tier, |t| note(&mut sink, t));
1252 +
            }
1171 1253
            return Ok(cancelled());
1172 1254
        }
1173 1255
        // The receipt says what a cached disk started from.
…
800 unchanged lines
modified docs/spec.md +13 −0
…
832 unchanged lines
833 833
the others at once, and any job still running when the notice is up. A
834 834
job with no expected time is handed back at once.
835 835
836 +
**Checkpoints (#77).** A job may name directories that hold its progress:
837 +
`checkpoint "/src/target"` (one or more, under /src, not /src itself).
838 +
When its runner hands it back, the runner packs them (files with their
839 +
modes and modification times, which cargo's fingerprints read; at most
840 +
512 MiB) and keeps them with `POST /api/v1/flow/jobs/{id}/checkpoint`
841 +
before the release; the claim of the next attempt names the blob
842 +
(`checkpoint`), and that runner fetches it (`GET` on the same path),
843 +
checks it, and restores it over the job's cache before running the same
844 +
exec, so a tool that skips finished work carries on. The receipt lists
845 +
the checkpoint among the job's inputs. As built: the WASM and syscall
846 +
tiers save and restore checkpoints, under a disk mount or in the tree;
847 +
the MicroVM tier, whose files are in an image, starts again for now.
848 +
836 849
As built (flow 0.3.2, OxygenHub 0.9.4): the runner as a library has
837 850
`runner::leave(reason)` (no notice) and `runner::leave_within(reason,
838 851
notice)` beside `runner::stop()`: each claims nothing more and stops the
…
18 unchanged lines