End the jobs a runner no longer holds at its next claim eef677c48cd1
A claim that lists the jobs its runner holds ends every other job the hub has as running on that runner, as an error with the reason, its run settled: the runner restarted since, or never received the claim. Pins OxygenFlow with ClaimRequest.holding. closes #71
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 03:06 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
eef677c48cd12866009f708934a18d3150fad03daebae548b71594668232d60a- Depends on
- 070a7b64b368 380996db84e5 42fa95540468 749d9a5fcd9f b0612e7dce30 b5668108487f b6b7e4cf6b24 ced6d3afc5cf
- Depended on by
- 5d90e0e045e2 348f8c4baccf d7ce41acf22c 34f40eb4b205 739a763a558c
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 6 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A restarted runner's lost jobs end at once (#71).** A runner lists the jobs it holds at each claim (OxygenFlow does), and the hub ends any other job it has as running on that runner, with the reason, rather than an hour later at the time limit. |
|
| 8 | + | ||
| 7 | 9 | - **Edit a release (#72).** `PATCH /api/v1/repos/{owner}/{name}/releases/{tag}` changes a release's title, notes or prerelease flag, keeping what it is not given, and `oh release edit TAG [--title] [--notes TEXT|-] [--prerelease true|false]` sends it. `oh api -X METHOD` without a body now sends the method asked for (#75). |
|
| 8 | 10 | ||
| 9 | 11 | - **Runners get only jobs they can run (#73).** A runner that lists the rootfs images it holds (OxygenFlow does, at every claim) is offered only jobs whose image is among them, rather than jobs it would fail for want of one. |
|
| … | 23 unchanged lines |
modified crates/hub/src/db/flow.rs +5 −0
| … | 413 unchanged lines |
||
| 414 | 414 | .await |
|
| 415 | 415 | } |
|
| 416 | 416 | ||
| 417 | + | /// The jobs running on the runner with this id. |
|
| 418 | + | pub async fn running_on(pool: &Pool, runner_id: i64) -> sqlx::Result<Vec<Job>> { |
|
| 419 | + | sqlx::query_as("SELECT * FROM flow_jobs WHERE status = 'running' AND runner_id = ? ORDER BY id").bind(runner_id).fetch_all(pool).await |
|
| 420 | + | } |
|
| 421 | + | ||
| 417 | 422 | pub struct Outcome<'a> { |
|
| 418 | 423 | pub status: &'a str, |
|
| 419 | 424 | pub exit_code: Option<i64>, |
|
| … | 146 unchanged lines |
modified crates/hub/src/flow.rs +21 −0
| … | 249 unchanged lines |
||
| 250 | 250 | let me = runner_auth(&app, &headers).await?; |
|
| 251 | 251 | let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect(); |
|
| 252 | 252 | db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?; |
|
| 253 | + | if let Some(holding) = &req.holding { |
|
| 254 | + | end_jobs_not_held(&app, &me, holding).await?; |
|
| 255 | + | } |
|
| 253 | 256 | // A draining runner finishes what it has and takes nothing new. |
|
| 254 | 257 | if me.state == "draining" { |
|
| 255 | 258 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| … | 295 unchanged lines |
||
| 551 | 554 | report_changed(app, job.run_id, changed).await?; |
|
| 552 | 555 | } |
|
| 553 | 556 | Ok(n) |
|
| 557 | + | } |
|
| 558 | + | ||
| 559 | + | /// End the jobs running on `runner` that it does not hold (oxygenhub#71): |
|
| 560 | + | /// it restarted since claiming them, or never received the claim. A |
|
| 561 | + | /// runner claims one job at a time, so a job it holds is always listed. |
|
| 562 | + | async fn end_jobs_not_held(app: &Shared, runner: &db::runners::Runner, holding: &[i64]) -> sqlx::Result<()> { |
|
| 563 | + | for job in fdb::running_on(&app.db, runner.id).await? { |
|
| 564 | + | if holding.contains(&job.id) { |
|
| 565 | + | continue; |
|
| 566 | + | } |
|
| 567 | + | let error = format!("lost: the runner {} no longer holds this job (it restarted, or never received it)", runner.name); |
|
| 568 | + | let changed = |
|
| 569 | + | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
|
| 570 | + | .await?; |
|
| 571 | + | tracing::warn!("flow: job {} {error}", job.id); |
|
| 572 | + | report_changed(app, job.run_id, changed).await?; |
|
| 573 | + | } |
|
| 574 | + | Ok(()) |
|
| 554 | 575 | } |
|
| 555 | 576 | ||
| 556 | 577 | /// Look for lost jobs once a minute. |
|
| … | 178 unchanged lines |
modified crates/hub/tests/flow.rs +32 −0
| … | 1397 unchanged lines |
||
| 1398 | 1398 | let (st, body) = claim_with(vec!["ab".repeat(32), rootfs]).await; |
|
| 1399 | 1399 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1400 | 1400 | } |
|
| 1401 | + | ||
| 1402 | + | /// oxygenhub#71: a runner that lists the jobs it holds has the others it |
|
| 1403 | + | /// was given ended at once (it restarted, or never received them), not at |
|
| 1404 | + | /// the time limit; a held job is left alone. |
|
| 1405 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1406 | + | async fn a_job_its_runner_no_longer_holds_is_ended_at_its_next_claim() { |
|
| 1407 | + | let h = hub().await; |
|
| 1408 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 1409 | + | create(&h.app, "andy", "held").await; |
|
| 1410 | + | let url = format!("{}/andy/held", h.base); |
|
| 1411 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1412 | + | let c = client(&url, work.path()); |
|
| 1413 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1414 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1415 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1416 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1417 | + | assert_eq!(runs_of(&h.app, "andy", "held").await.len(), 1); |
|
| 1418 | + | let id = claim(&h.app).await.expect("a job to claim")["job_id"].as_i64().unwrap(); |
|
| 1419 | + | let claim_holding = |holding: Vec<i64>| { |
|
| 1420 | + | let body = serde_json::json!({ "runner": "test-1", "runtimes": ["microvm"], "holding": holding }).to_string(); |
|
| 1421 | + | let app = h.app.clone(); |
|
| 1422 | + | async move { call(&app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body)).await.0 } |
|
| 1423 | + | }; |
|
| 1424 | + | claim_holding(vec![id]).await; |
|
| 1425 | + | assert_eq!(db::flow::job(&h.app.db, id).await.unwrap().unwrap().status, "running", "a held job stays"); |
|
| 1426 | + | claim_holding(vec![]).await; |
|
| 1427 | + | let j = db::flow::job(&h.app.db, id).await.unwrap().unwrap(); |
|
| 1428 | + | assert_eq!(j.status, "error"); |
|
| 1429 | + | assert!(j.error.starts_with("lost:") && j.error.contains("no longer holds"), "{}", j.error); |
|
| 1430 | + | let run = &runs_of(&h.app, "andy", "held").await[0]; |
|
| 1431 | + | assert!(db::flow::jobs(&h.app.db, run.id).await.unwrap().iter().all(|j| j.status != "running"), "the run settles"); |
|
| 1432 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.