Stop a job when its run is cancelled 280ef951d193
The microVM wait loop now asks its sink, a few times a second, whether the job is still wanted. The runner's log sink sends an empty batch every 5 s while a job is quiet. When the hub answers 409, the job is cancelled: the microVM is stopped and nothing more is sent or reported. A job cancelled while it was being prepared boots no microVM. refs #32
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 22:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
280ef951d193257d4f21659467ee66d2f13d5759def4a1e1a219c80f25fe1c07- Depends on
- 1b2229dab1a4 1daca0b6bb8c 3007d8f011fa a67b97e9a717 bb643c252515 e0918b887850
- Depended on by
- 1215f05ff8d0 f201e1bc5bcd 1b160d372d9e 06a4c472b385 28cc603f737d 4be5e4cd0af6 077a65242359 89171fca079c d60aac6c3f8a 8f211e20bea5 ea56c56eaf90
- In bookmarks
- main newer-pipeline push-uses-login
- 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
- 29 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/flow/src/microvm.rs +49 −12
| … | 17 unchanged lines |
||
| 18 | 18 | pub duration_ms: u64, |
|
| 19 | 19 | pub environment: Environment, |
|
| 20 | 20 | pub timed_out: bool, |
|
| 21 | + | /// Stopped because the run was cancelled (oxygenflow#32). |
|
| 22 | + | pub cancelled: bool, |
|
| 21 | 23 | } |
|
| 22 | 24 | ||
| 25 | + | /// Where a job's records go, and whether it is still wanted: asked a few |
|
| 26 | + | /// times a second while the job runs, and the microVM is stopped on `true`. |
|
| 27 | + | pub trait Sink { |
|
| 28 | + | fn record(&mut self, r: Record); |
|
| 29 | + | fn cancelled(&mut self) -> bool { |
|
| 30 | + | false |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | impl<F: FnMut(Record)> Sink for F { |
|
| 35 | + | fn record(&mut self, r: Record) { |
|
| 36 | + | self(r) |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | ||
| 23 | 40 | /// The receipt environment for a job in this tier. |
|
| 24 | 41 | pub fn environment(home: &Home, sandbox: &Sandbox, job: &Job) -> Result<Environment> { |
|
| 25 | 42 | let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?; |
|
| … | 23 unchanged lines |
||
| 49 | 66 | workspace: &Path, |
|
| 50 | 67 | scratch: &Path, |
|
| 51 | 68 | timeout: Duration, |
|
| 52 | - | sink: &mut dyn FnMut(Record), |
|
| 69 | + | sink: &mut dyn Sink, |
|
| 53 | 70 | ) -> Result<Outcome> { |
|
| 54 | 71 | let cfg = home.config.microvm.as_ref().context("flow.toml has no [microvm] section")?; |
|
| 55 | 72 | let firecracker = home.verified(&home.resolve(&cfg.firecracker.path), &cfg.firecracker.blake3)?; |
|
| … | 64 unchanged lines |
||
| 120 | 137 | let readers = [std::thread::spawn(move || lines(out, true, tx)), std::thread::spawn(move || lines(err, false, tx2))]; |
|
| 121 | 138 | let mut exit_code = None; |
|
| 122 | 139 | let mut timed_out = false; |
|
| 140 | + | let mut cancelled = false; |
|
| 123 | 141 | let deadline = started + timeout; |
|
| 124 | 142 | let t = |s: &Instant| s.elapsed().as_millis() as u64; |
|
| 125 | 143 | loop { |
|
| … | 2 unchanged lines |
||
| 128 | 146 | let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string(); |
|
| 129 | 147 | let rec = |stream: &str, text: &str| Record { t: t(&started), stream: stream.into(), text: text.into() }; |
|
| 130 | 148 | if !from_console { |
|
| 131 | - | sink(rec("console", &format!("firecracker: {text}"))); |
|
| 149 | + | sink.record(rec("console", &format!("firecracker: {text}"))); |
|
| 132 | 150 | } else if let Some(rest) = text.strip_prefix('\u{1e}') { |
|
| 133 | 151 | let (kind, body) = rest.split_at(rest.len().min(1)); |
|
| 134 | 152 | let body = body.strip_prefix(' ').unwrap_or(body); |
|
| 135 | 153 | match kind { |
|
| 136 | - | "O" => sink(rec("out", body)), |
|
| 137 | - | "E" => sink(rec("err", body)), |
|
| 154 | + | "O" => sink.record(rec("out", body)), |
|
| 155 | + | "E" => sink.record(rec("err", body)), |
|
| 138 | 156 | "X" => exit_code = body.trim().parse().ok(), |
|
| 139 | - | "F" => sink(rec("flow", &format!("guest init failed: {body}"))), |
|
| 140 | - | _ => sink(rec("console", &text)), |
|
| 157 | + | "F" => sink.record(rec("flow", &format!("guest init failed: {body}"))), |
|
| 158 | + | _ => sink.record(rec("console", &text)), |
|
| 141 | 159 | } |
|
| 142 | 160 | } else if !text.is_empty() { |
|
| 143 | - | sink(rec("console", &text)); |
|
| 161 | + | sink.record(rec("console", &text)); |
|
| 144 | 162 | } |
|
| 145 | 163 | } |
|
| 146 | 164 | Err(mpsc::RecvTimeoutError::Timeout) => {} |
|
| 147 | 165 | Err(mpsc::RecvTimeoutError::Disconnected) => break, |
|
| 166 | + | } |
|
| 167 | + | if !timed_out && !cancelled && sink.cancelled() { |
|
| 168 | + | cancelled = true; |
|
| 169 | + | let _ = child.kill(); |
|
| 170 | + | sink.record(Record { t: t(&started), stream: "flow".into(), text: "stopped: the run was cancelled".into() }); |
|
| 148 | 171 | } |
|
| 149 | - | if Instant::now() >= deadline && !timed_out { |
|
| 172 | + | if Instant::now() >= deadline && !timed_out && !cancelled { |
|
| 150 | 173 | timed_out = true; |
|
| 151 | 174 | let _ = child.kill(); |
|
| 152 | - | sink(Record { t: t(&started), stream: "flow".into(), text: format!("stopped at the time limit of {} s", timeout.as_secs()) }); |
|
| 175 | + | sink.record(Record { |
|
| 176 | + | t: t(&started), |
|
| 177 | + | stream: "flow".into(), |
|
| 178 | + | text: format!("stopped at the time limit of {} s", timeout.as_secs()), |
|
| 179 | + | }); |
|
| 153 | 180 | } |
|
| 154 | 181 | } |
|
| 155 | 182 | let status = child.wait()?; |
|
| 156 | 183 | for r in readers { |
|
| 157 | 184 | let _ = r.join(); |
|
| 158 | 185 | } |
|
| 159 | - | if exit_code.is_none() && !timed_out { |
|
| 160 | - | sink(Record { t: t(&started), stream: "flow".into(), text: format!("the microVM ended without the job's exit code ({status})") }); |
|
| 186 | + | if exit_code.is_none() && !timed_out && !cancelled { |
|
| 187 | + | sink.record(Record { |
|
| 188 | + | t: t(&started), |
|
| 189 | + | stream: "flow".into(), |
|
| 190 | + | text: format!("the microVM ended without the job's exit code ({status})"), |
|
| 191 | + | }); |
|
| 161 | 192 | } |
|
| 162 | - | Ok(Outcome { exit_code: if timed_out { None } else { exit_code }, duration_ms: t(&started), environment, timed_out }) |
|
| 193 | + | Ok(Outcome { |
|
| 194 | + | exit_code: if timed_out || cancelled { None } else { exit_code }, |
|
| 195 | + | duration_ms: t(&started), |
|
| 196 | + | environment, |
|
| 197 | + | timed_out, |
|
| 198 | + | cancelled, |
|
| 199 | + | }) |
|
| 163 | 200 | } |
|
| 164 | 201 | ||
| 165 | 202 | /// What `flow-init` reads from `/job.json`: argv, env, and the mounts |
|
| … | 61 unchanged lines |
modified crates/flow/src/runner.rs +103 −7
| … | 12 unchanged lines |
||
| 13 | 13 | use std::path::PathBuf; |
|
| 14 | 14 | use std::time::{Duration, Instant}; |
|
| 15 | 15 | ||
| 16 | + | /// A request the hub refused (4xx). On a job's endpoints, 409 means the |
|
| 17 | + | /// job no longer runs: it was cancelled, or given up. |
|
| 18 | + | #[derive(Debug)] |
|
| 19 | + | pub struct Refused { |
|
| 20 | + | pub code: u16, |
|
| 21 | + | pub text: String, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl std::fmt::Display for Refused { |
|
| 25 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 26 | + | write!(f, "HTTP {}: {}", self.code, self.text) |
|
| 27 | + | } |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | impl std::error::Error for Refused {} |
|
| 31 | + | ||
| 32 | + | /// Whether `e` is the hub saying the job no longer runs. |
|
| 33 | + | fn job_gone(e: &anyhow::Error) -> bool { |
|
| 34 | + | e.downcast_ref::<Refused>().is_some_and(|r| r.code == 409) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | /// What a job ends with when its run was cancelled: nothing is reported, |
|
| 38 | + | /// as the hub has ended it already. |
|
| 39 | + | const CANCELLED: &str = "the run was cancelled"; |
|
| 40 | + | ||
| 41 | + | fn cancelled() -> Finish { |
|
| 42 | + | Finish { exit_code: None, duration_ms: 0, receipt: None, signed_receipt: None, error: Some(CANCELLED.into()) } |
|
| 43 | + | } |
|
| 44 | + | ||
| 16 | 45 | pub struct Hub { |
|
| 17 | 46 | base: String, |
|
| 18 | 47 | token: String, |
|
| … | 25 unchanged lines |
||
| 44 | 73 | let text = r.into_string().unwrap_or_default(); |
|
| 45 | 74 | match delays.next() { |
|
| 46 | 75 | Some(d) if code >= 500 => std::thread::sleep(*d), |
|
| 76 | + | _ if code < 500 => return Err(anyhow::Error::new(Refused { code, text }).context(url)), |
|
| 47 | 77 | _ => bail!("{url}: HTTP {code}: {text}"), |
|
| 48 | 78 | } |
|
| 49 | 79 | } |
|
| … | 279 unchanged lines |
||
| 329 | 359 | finish.exit_code, |
|
| 330 | 360 | finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default() |
|
| 331 | 361 | ); |
|
| 332 | - | report_finish(home, hub, id, &finish); |
|
| 362 | + | if finish.error.as_deref() != Some(CANCELLED) { |
|
| 363 | + | report_finish(home, hub, id, &finish); |
|
| 364 | + | } |
|
| 333 | 365 | held.lock().unwrap().remove(&id); |
|
| 334 | 366 | let mut u = used.lock().unwrap(); |
|
| 335 | 367 | u.jobs -= 1; |
|
| … | 12 unchanged lines |
||
| 348 | 380 | }) |
|
| 349 | 381 | } |
|
| 350 | 382 | ||
| 351 | - | /// Batches records to the hub every second (or every 200 lines). |
|
| 383 | + | /// Batches records to the hub every second (or every 200 lines). A quiet |
|
| 384 | + | /// job's sink still asks the hub every few seconds whether the job runs, |
|
| 385 | + | /// with an empty batch; once the hub answers 409 the job is not wanted. |
|
| 352 | 386 | struct LogSink<'a> { |
|
| 353 | 387 | hub: &'a Hub, |
|
| 354 | 388 | job: i64, |
|
| 355 | 389 | buf: Vec<Record>, |
|
| 356 | 390 | last: Instant, |
|
| 391 | + | gone: bool, |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | /// How often a quiet job asks whether it was cancelled. |
|
| 395 | + | const HEARTBEAT: Duration = Duration::from_secs(5); |
|
| 396 | + | ||
| 397 | + | impl microvm::Sink for LogSink<'_> { |
|
| 398 | + | fn record(&mut self, r: Record) { |
|
| 399 | + | self.push(r) |
|
| 400 | + | } |
|
| 401 | + | ||
| 402 | + | fn cancelled(&mut self) -> bool { |
|
| 403 | + | if !self.gone && self.last.elapsed() >= HEARTBEAT { |
|
| 404 | + | self.send(); |
|
| 405 | + | } |
|
| 406 | + | self.gone |
|
| 407 | + | } |
|
| 357 | 408 | } |
|
| 358 | 409 | ||
| 359 | 410 | impl LogSink<'_> { |
|
| 360 | 411 | fn push(&mut self, mut r: Record) { |
|
| 412 | + | if self.gone { |
|
| 413 | + | return; |
|
| 414 | + | } |
|
| 361 | 415 | if r.text.len() > MAX_RECORD_TEXT { |
|
| 362 | 416 | let mut cut = MAX_RECORD_TEXT; |
|
| 363 | 417 | while !r.text.is_char_boundary(cut) { |
|
| … | 9 unchanged lines |
||
| 373 | 427 | ||
| 374 | 428 | fn flush(&mut self) { |
|
| 375 | 429 | if !self.buf.is_empty() { |
|
| 376 | - | if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) { |
|
| 377 | - | eprintln!("job {}: log: {e:#}", self.job); |
|
| 378 | - | } |
|
| 430 | + | self.send(); |
|
| 431 | + | } |
|
| 432 | + | self.last = Instant::now(); |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | /// Send what is buffered, even nothing. |
|
| 436 | + | fn send(&mut self) { |
|
| 437 | + | if self.gone { |
|
| 438 | + | return; |
|
| 439 | + | } |
|
| 440 | + | match self.hub.log(self.job, std::mem::take(&mut self.buf)) { |
|
| 441 | + | Ok(()) => {} |
|
| 442 | + | Err(e) if job_gone(&e) => self.gone = true, |
|
| 443 | + | Err(e) => eprintln!("job {}: log: {e:#}", self.job), |
|
| 379 | 444 | } |
|
| 380 | 445 | self.last = Instant::now(); |
|
| 381 | 446 | } |
|
| … | 4 unchanged lines |
||
| 386 | 451 | ||
| 387 | 452 | fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, key: &RunnerKey, claim: &Claim) -> Result<Finish> { |
|
| 388 | 453 | let started = Instant::now(); |
|
| 389 | - | let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() }; |
|
| 454 | + | let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now(), gone: false }; |
|
| 390 | 455 | let note = |
|
| 391 | 456 | |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text }); |
|
| 392 | 457 | let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?; |
|
| … | 36 unchanged lines |
||
| 429 | 494 | workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?; |
|
| 430 | 495 | std::fs::remove_dir_all(scratch.join("tree"))?; |
|
| 431 | 496 | preparing.take(); |
|
| 497 | + | // Cancelled while it was being prepared: no microVM. |
|
| 498 | + | if microvm::Sink::cancelled(&mut sink) { |
|
| 499 | + | return Ok(cancelled()); |
|
| 500 | + | } |
|
| 432 | 501 | note( |
|
| 433 | 502 | &mut sink, |
|
| 434 | 503 | format!( |
|
| … | 11 unchanged lines |
||
| 446 | 515 | &scratch.join("workspace.ext4"), |
|
| 447 | 516 | &scratch, |
|
| 448 | 517 | Duration::from_secs(claim.timeout_s), |
|
| 449 | - | &mut |r| sink.push(r), |
|
| 518 | + | &mut sink, |
|
| 450 | 519 | )?; |
|
| 520 | + | if outcome.cancelled { |
|
| 521 | + | return Ok(cancelled()); |
|
| 522 | + | } |
|
| 451 | 523 | // A passing job's artifacts, read from its disks and uploaded; a |
|
| 452 | 524 | // declared artifact that cannot be kept fails the job. |
|
| 453 | 525 | let mut outputs = vec![]; |
|
| … | 240 unchanged lines |
||
| 694 | 766 | let (base, seen) = fake_hub(vec![401]); |
|
| 695 | 767 | assert!(hub_at(base).post_with("/flow/claim", &serde_json::json!({}), &quick).is_err()); |
|
| 696 | 768 | assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final"); |
|
| 769 | + | } |
|
| 770 | + | ||
| 771 | + | /// oxygenflow#32: a quiet job asks the hub every few seconds whether |
|
| 772 | + | /// it still runs; a 409 marks it cancelled, and nothing more is sent. |
|
| 773 | + | #[test] |
|
| 774 | + | fn a_quiet_job_learns_that_its_run_was_cancelled() { |
|
| 775 | + | use microvm::Sink; |
|
| 776 | + | let n = |seen: &std::sync::Arc<std::sync::atomic::AtomicUsize>| seen.load(std::sync::atomic::Ordering::SeqCst); |
|
| 777 | + | let quiet = Instant::now() - HEARTBEAT - Duration::from_secs(1); |
|
| 778 | + | let (base, seen) = fake_hub(vec![204]); |
|
| 779 | + | let hub = hub_at(base); |
|
| 780 | + | let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false }; |
|
| 781 | + | assert!(!sink.cancelled()); |
|
| 782 | + | assert!(!sink.cancelled(), "asked again only after the heartbeat"); |
|
| 783 | + | assert_eq!(n(&seen), 1); |
|
| 784 | + | ||
| 785 | + | let (base, seen) = fake_hub(vec![409]); |
|
| 786 | + | let hub = hub_at(base); |
|
| 787 | + | let mut sink = LogSink { hub: &hub, job: 1, buf: Vec::new(), last: quiet, gone: false }; |
|
| 788 | + | assert!(sink.cancelled()); |
|
| 789 | + | sink.record(Record { t: 0, stream: "out".into(), text: "late".into() }); |
|
| 790 | + | sink.flush(); |
|
| 791 | + | assert!(sink.cancelled()); |
|
| 792 | + | assert_eq!(n(&seen), 1, "nothing is sent for a cancelled job"); |
|
| 697 | 793 | } |
|
| 698 | 794 | ||
| 699 | 795 | #[test] |
|
| … | 14 unchanged lines |
modified docs/m0.md +1 −1
| … | 188 unchanged lines |
||
| 189 | 189 | compiled form and its sandbox, and a **job token**: read access to that |
|
| 190 | 190 | repository only, valid until the job finishes or 2 hours pass. `204` |
|
| 191 | 191 | when there is nothing. |
|
| 192 | - | - `POST /api/v1/flow/jobs/{id}/log` appends records `{t, stream, text}`. |
|
| 192 | + | - `POST /api/v1/flow/jobs/{id}/log` appends records `{t, stream, text}`. An empty batch only asks whether the job still runs; the runner sends one every 5 s while a job is quiet, and stops the job's microVM when the hub answers `409` (the run was cancelled, oxygenflow#32). A cancelled job's end is not reported. |
|
| 193 | 193 | - `POST /api/v1/flow/jobs/{id}/finish` with the exit code, duration and |
|
| 194 | 194 | receipt; a job whose dependency failed becomes `skipped`. |
|
| 195 | 195 | - Each job is also a check named `flow/<job>` on the state (hub spec §13), |
|
| … | 20 unchanged lines |