Checkpoints, so a handed-back job resumes (refs andymccall/oxygenflow#77) e4203e141e90
POST and GET /api/v1/flow/jobs/{id}/checkpoint: a runner handing a job
back keeps the directories it declares with checkpoint, as a blob of the
job's repository, and the next attempt's claim names it for its runner
to restore. Kept beside the job's artifacts as .checkpoint, which no
pipeline can declare and no receipt names, so it is left out of them.
Pins OxygenFlow's main.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b265f3d32881
- When
- · 2026-10-25 22:24 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e4203e141e90bb28409a102a527e3af1b341c1d61a1091793b724a58fbbd1ad0- Depends on
- 10708ab8f54d 16ee5d19d220 380996db84e5 3b65065298b7 42fa95540468 4597750166e7 5d90e0e045e2 946fe7a09faa a36bab6b9ffc a3ba7cc10673 a3c8288b0141 ab4df6aae179 b5668108487f d23464c0ac60
- Depended on by
- 8501fd5815b9
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | ||
| 7 | 7 | ## 0.9.4 (2026-10-25) |
|
| 8 | 8 | ||
| 9 | + | - **Checkpoints, so a handed-back job resumes (oxygenflow#77):** `POST /api/v1/flow/jobs/{id}/checkpoint` keeps the directories a job declares with `checkpoint "/src/…"` as its runner hands it back; the next attempt's claim names the blob (`checkpoint`) and its runner fetches it (`GET`) and restores it before running. A checkpoint is kept beside the job's artifacts but is none of them (no receipt names it). Pins OxygenFlow's main. |
|
| 9 | 10 | - **Boards keep the last fortnight of finished work (#293):** by default every column archives its finished cards (closed issues, closed or landed proposals) 14 days after they reached it, hourly, so a Done column stops growing however long a project runs; open work and notes stay. A column can instead archive every card after N days, or never ("Archive automatically" in its menu). Archived cards stay on the board's archive view, searchable and restorable. On the next hourly pass, boards with years of Done cards (Super Mario Land 2's 327) shrink to their last two weeks. |
|
| 10 | 11 | - **An issue's references: no line through the chips, each change once (#318):** an opened "referenced by N changes" box drew the proposal timeline's vertical rule through its first column (the events shared its class); and a change was recorded again for every bookmark it was pushed on, and for every mention in its message, so one change filled several chips. Now one reference per change per issue or proposal, and the page shows each change once, including those recorded before. |
|
| 11 | 12 | - **Claims say how long a job is expected to take (oxygenflow#76):** `expected_ms`, the median of the job's last five passing runs in the repository (reused passes left out), so a runner leaving with notice finishes a job that fits it and hands back the rest. Pins OxygenFlow's main. |
|
| … | 324 unchanged lines |
modified crates/hub/src/api/mod.rs +6 −0
| … | 72 unchanged lines |
||
| 73 | 73 | .route("/flow/jobs/{id}/decline", post(crate::flow::decline)) |
|
| 74 | 74 | .route("/flow/jobs/{id}/release", post(crate::flow::release)) |
|
| 75 | 75 | .route( |
|
| 76 | + | "/flow/jobs/{id}/checkpoint", |
|
| 77 | + | post(crate::flow::save_checkpoint) |
|
| 78 | + | .get(crate::flow::checkpoint) |
|
| 79 | + | .layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| 80 | + | ) |
|
| 81 | + | .route( |
|
| 76 | 82 | "/flow/jobs/{id}/artifacts/{name}", |
|
| 77 | 83 | post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| 78 | 84 | ) |
|
| … | 2986 unchanged lines |
modified crates/hub/src/db/flow.rs +13 −2
| … | 1384 unchanged lines |
||
| 1385 | 1385 | .await |
|
| 1386 | 1386 | } |
|
| 1387 | 1387 | ||
| 1388 | - | /// Job `job_id`'s artifacts, by name. |
|
| 1388 | + | /// Job `job_id`'s artifacts, by name; not its [`CHECKPOINT`], which no |
|
| 1389 | + | /// receipt names. |
|
| 1389 | 1390 | pub async fn artifacts(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<Artifact>> { |
|
| 1390 | - | sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await |
|
| 1391 | + | sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 AND name NOT LIKE '.%' ORDER BY name").bind(job_id).fetch_all(pool).await |
|
| 1391 | 1392 | } |
|
| 1392 | 1393 | ||
| 1394 | + | /// The name a job's checkpoint is kept under (oxygenflow#77), beside its |
|
| 1395 | + | /// artifacts: no pipeline can declare it. |
|
| 1396 | + | pub const CHECKPOINT: &str = ".checkpoint"; |
|
| 1397 | + | ||
| 1398 | + | /// The checkpoint an attempt of job `job_id` saved as its runner handed it |
|
| 1399 | + | /// back (oxygenflow#77), if one did. |
|
| 1400 | + | pub async fn checkpoint(pool: &Pool, job_id: i64) -> sqlx::Result<Option<Artifact>> { |
|
| 1401 | + | sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 AND name = $2").bind(job_id).bind(CHECKPOINT).fetch_optional(pool).await |
|
| 1402 | + | } |
|
| 1403 | + | ||
| 1393 | 1404 | /// An artifact with the run and job that made it, for a repository's list |
|
| 1394 | 1405 | /// of every run's outputs (oxygenhub#254). |
|
| 1395 | 1406 | #[derive(Clone, Debug, FromRow)] |
|
| … | 115 unchanged lines |
modified crates/hub/src/flow.rs +70 −0
| … | 1166 unchanged lines |
||
| 1167 | 1167 | // For a runner that must leave: finish it, or hand it back |
|
| 1168 | 1168 | // (oxygenflow#76). |
|
| 1169 | 1169 | expected_ms: fdb::expected_ms(&app.db, row.id, &job.name).await?.map(|ms| ms as u64), |
|
| 1170 | + | // What an attempt handed back saved, to resume from (oxygenflow#77). |
|
| 1171 | + | checkpoint: if job.handbacks > 0 { fdb::checkpoint(&app.db, job.id).await?.map(|a| a.blob) } else { None }, |
|
| 1170 | 1172 | }; |
|
| 1171 | 1173 | let mut claim = claim; |
|
| 1172 | 1174 | let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await; |
|
| … | 206 unchanged lines |
||
| 1379 | 1381 | Ok(n) |
|
| 1380 | 1382 | } |
|
| 1381 | 1383 | ||
| 1384 | + | /// `POST /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): a runner |
|
| 1385 | + | /// handing a job back keeps its checkpoint directories, packed; kept as a |
|
| 1386 | + | /// blob of the job's repository, for the next attempt's claim to name. |
|
| 1387 | + | pub async fn save_checkpoint( |
|
| 1388 | + | State(app): State<Shared>, |
|
| 1389 | + | headers: HeaderMap, |
|
| 1390 | + | Path(id): Path<i64>, |
|
| 1391 | + | body: axum::body::Bytes, |
|
| 1392 | + | ) -> Result<Response, ApiError> { |
|
| 1393 | + | let runner = runner_auth(&app, &headers).await?; |
|
| 1394 | + | let job = running_job(&app, id).await?; |
|
| 1395 | + | if runner.source == "registered" && job.runner != runner.name { |
|
| 1396 | + | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
|
| 1397 | + | } |
|
| 1398 | + | let spec: flow_core::Job = |
|
| 1399 | + | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
|
| 1400 | + | if spec.checkpoints.is_empty() { |
|
| 1401 | + | return Err(ApiError(StatusCode::BAD_REQUEST, format!("job {} declares no checkpoint", job.name))); |
|
| 1402 | + | } |
|
| 1403 | + | let repo = job_repo(&app, &job).await?; |
|
| 1404 | + | let content = blake3::hash(&body).to_hex().to_string(); |
|
| 1405 | + | let size = body.len() as i64; |
|
| 1406 | + | let blob = app |
|
| 1407 | + | .repos |
|
| 1408 | + | .write(&repo, move |r, _| Ok(r.store_blob(&body)?)) |
|
| 1409 | + | .await |
|
| 1410 | + | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the checkpoint: {e}")))?; |
|
| 1411 | + | let a = fdb::Artifact { |
|
| 1412 | + | job_id: job.id, |
|
| 1413 | + | name: fdb::CHECKPOINT.into(), |
|
| 1414 | + | path: spec.checkpoints.join(" "), |
|
| 1415 | + | blob: blob.to_hex(), |
|
| 1416 | + | size, |
|
| 1417 | + | content, |
|
| 1418 | + | created_ms: db::now_ms(), |
|
| 1419 | + | }; |
|
| 1420 | + | fdb::add_artifact(&app.db, &a).await?; |
|
| 1421 | + | Ok(Json(serde_json::json!({ "blob": a.blob })).into_response()) |
|
| 1422 | + | } |
|
| 1423 | + | ||
| 1424 | + | /// `GET /api/v1/flow/jobs/{id}/checkpoint` (oxygenflow#77): the checkpoint |
|
| 1425 | + | /// a handed-back attempt saved, for the attempt now running it. |
|
| 1426 | + | pub async fn checkpoint(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<Response, ApiError> { |
|
| 1427 | + | let runner = runner_auth(&app, &headers).await?; |
|
| 1428 | + | let job = running_job(&app, id).await?; |
|
| 1429 | + | if runner.source == "registered" && job.runner != runner.name { |
|
| 1430 | + | return Err(ApiError(StatusCode::FORBIDDEN, "this job is held by another runner".into())); |
|
| 1431 | + | } |
|
| 1432 | + | let a = fdb::checkpoint(&app.db, job.id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "this job has no checkpoint".into()))?; |
|
| 1433 | + | let repo = job_repo(&app, &job).await?; |
|
| 1434 | + | let blob = oxy_core::hash::ObjectRef::from_hex(&a.blob) |
|
| 1435 | + | .ok_or_else(|| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "a kept checkpoint has a bad blob".into()))?; |
|
| 1436 | + | let bytes = app |
|
| 1437 | + | .repos |
|
| 1438 | + | .read(&repo, move |r, _| Ok(r.read_blob(&blob)?)) |
|
| 1439 | + | .await |
|
| 1440 | + | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("read the checkpoint: {e}")))?; |
|
| 1441 | + | Ok(([(axum::http::header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response()) |
|
| 1442 | + | } |
|
| 1443 | + | ||
| 1444 | + | /// The repository job `job` runs for. |
|
| 1445 | + | async fn job_repo(app: &Shared, job: &fdb::Job) -> Result<RepoName, ApiError> { |
|
| 1446 | + | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 1447 | + | let row = |
|
| 1448 | + | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
|
| 1449 | + | RepoName::new(&row.owner, &row.name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "bad repository name".into())) |
|
| 1450 | + | } |
|
| 1451 | + | ||
| 1382 | 1452 | /// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining |
|
| 1383 | 1453 | /// runner hands back a job it holds, as it leaves (off the allowed network, |
|
| 1384 | 1454 | /// a restart, a spot reclaim). Not a decline: the runner is not held from |
|
| … | 1551 unchanged lines |
modified crates/hub/tests/flow.rs +16 −2
| … | 2631 unchanged lines |
||
| 2632 | 2632 | let url = format!("{}/andy/drains", h.base); |
|
| 2633 | 2633 | let work = tempfile::tempdir().unwrap(); |
|
| 2634 | 2634 | let c = client(&url, work.path()); |
|
| 2635 | - | let bulk = "job \"bulk\" {\n sandbox \"alpine\"\n priority \"low\"\n exec \"true\"\n}\n"; |
|
| 2635 | + | let bulk = "job \"bulk\" {\n sandbox \"alpine\"\n priority \"low\"\n checkpoint \"/src/state\"\n exec \"true\"\n}\n"; |
|
| 2636 | 2636 | write(work.path(), ".flow/pipeline.kdl", &pipeline(bulk)); |
|
| 2637 | 2637 | write(work.path(), "test.sh", "echo ok\n"); |
|
| 2638 | 2638 | c.commit("Add a pipeline", &[]).unwrap(); |
|
| … | 26 unchanged lines |
||
| 2665 | 2665 | let line = serde_json::json!({ "records": [{ "t": 1200, "stream": "stdout", "text": "step 1 of 30" }] }).to_string(); |
|
| 2666 | 2666 | call(&h.app, "POST", &format!("/api/v1/flow/jobs/{low}/log"), Some("runner-three"), Some(line)).await; |
|
| 2667 | 2667 | assert_eq!(finish(&h.app, build, serde_json::json!({ "exit_code": 0, "duration_ms": 700 })).await, StatusCode::NO_CONTENT); |
|
| 2668 | + | // It keeps its checkpoint first (oxygenflow#77), a job's own only. |
|
| 2669 | + | let (st, body) = call(&h.app, "POST", &format!("/api/v1/flow/jobs/{lint}/checkpoint"), Some("runner-two"), Some("x".into())).await; |
|
| 2670 | + | assert_eq!(st, StatusCode::BAD_REQUEST, "lint declares no checkpoint: {body}"); |
|
| 2671 | + | let (st, body) = |
|
| 2672 | + | call(&h.app, "POST", &format!("/api/v1/flow/jobs/{low}/checkpoint"), Some("runner-three"), Some("step 32".into())).await; |
|
| 2673 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 2674 | + | let saved = serde_json::from_str::<serde_json::Value>(&body).unwrap()["blob"].as_str().unwrap().to_string(); |
|
| 2668 | 2675 | assert_eq!(release("runner-two", low, "off Wi-Fi").await, StatusCode::CONFLICT, "only the runner that holds it"); |
|
| 2669 | 2676 | assert_eq!(release("runner-three", low, "off Wi-Fi").await, StatusCode::NO_CONTENT); |
|
| 2670 | 2677 | let j = db::flow::job(&h.app.db, low).await.unwrap().unwrap(); |
|
| … | 3 unchanged lines |
||
| 2674 | 2681 | assert!(db::flow::declines_of_job(&h.app.db, low).await.unwrap().is_empty(), "not a decline"); |
|
| 2675 | 2682 | // `test` became ready when `build` passed, and is of higher priority: |
|
| 2676 | 2683 | // the handed-back job still goes first, to any runner, its own too. |
|
| 2677 | - | assert_eq!(claim_as("runner-three", "phone").await, Some(low)); |
|
| 2684 | + | let body = serde_json::json!({ "runner": "phone", "runtimes": ["microvm"] }).to_string(); |
|
| 2685 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-three"), Some(body)).await; |
|
| 2686 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 2687 | + | let again: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 2688 | + | assert_eq!((again["job_id"].as_i64(), again["checkpoint"].as_str()), (Some(low), Some(saved.as_str())), "it resumes: {again}"); |
|
| 2689 | + | let (st, bytes) = call(&h.app, "GET", &format!("/api/v1/flow/jobs/{low}/checkpoint"), Some("runner-three"), None).await; |
|
| 2690 | + | assert_eq!((st, bytes.as_str()), (StatusCode::OK, "step 32")); |
|
| 2691 | + | assert!(db::flow::artifacts(&h.app.db, low).await.unwrap().is_empty(), "a checkpoint is no artifact of the job"); |
|
| 2678 | 2692 | assert_eq!(claim_as("runner-secret", "earth-1").await.map(|id| id != low), Some(true), "then new work"); |
|
| 2679 | 2693 | ||
| 2680 | 2694 | // The phone goes quiet: once its lease lapses the job is handed back. |
|
| … | 3869 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.