OXYGENHUB

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.