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 |