OXYGENHUB

Runners hand jobs back, and the queue takes them first (closes #312, refs andymccall/oxygenflow#76) ab4df6aae179

POST /api/v1/flow/jobs/{id}/release lets a draining runner hand back a
job it holds as it leaves; the job is queued again ahead of new work,
its log says why and goes on with the next attempt, and it is not a
decline. A runner silent for 3 minutes has its jobs handed back the
same way instead of their running on to their time limit as lost. Five
hand-backs and the job ends as an error. Migration 0094.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f27f7a426d79
When
· 2026-10-25 17:47 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ab4df6aae179d6956437423a453c9b4f0fecab758c186b841557059162d58cb5
Depends on
070a7b64b368 107eaba52c9d 3b1bc27fe805 417184c73f4e 42fa95540468 4d2e44d79b03 554ccacfc123 5d90e0e045e2 73c01b3448ee a3c8288b0141 a7e6e8e6094c b5668108487f d23464c0ac60 e97500b70f9a
Depended on by
0468394734e9 e4203e141e90 3b65065298b7 a36c79fd9493 234921ea0834
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready 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.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 2 tree operations, 7 files

Browse the tree at this change

added crates/hub/migrations-pg/0094_job_handback.sql +6 −0
1 +
-- Jobs a runner handed back (oxygenflow#76, oxygenhub#312): drained before
2 +
-- it left, or its lease lapsed. Queued again ahead of new work; NULL if it
3 +
-- never was.
4 +
ALTER TABLE flow_jobs ADD COLUMN handed_back_ms BIGINT;
5 +
-- How many times; past a few it ends as an error rather than bounce on.
6 +
ALTER TABLE flow_jobs ADD COLUMN handbacks BIGINT NOT NULL DEFAULT 0;
added crates/hub/migrations/0094_job_handback.sql +6 −0
1 +
-- Jobs a runner handed back (oxygenflow#76, oxygenhub#312): drained before
2 +
-- it left, or its lease lapsed. Queued again ahead of new work; NULL if it
3 +
-- never was.
4 +
ALTER TABLE flow_jobs ADD COLUMN handed_back_ms INTEGER;
5 +
-- How many times; past a few it ends as an error rather than bounce on.
6 +
ALTER TABLE flow_jobs ADD COLUMN handbacks INTEGER NOT NULL DEFAULT 0;
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Runners hand jobs back, and the queue takes them first (#312, oxygenflow#76):** `POST /api/v1/flow/jobs/{id}/release` {reason} lets a draining runner hand back a job it holds as it leaves (a phone off Wi-Fi, a restart, a spot reclaim). The job is queued again **ahead of new work**, whatever its priority; its log says who handed it back and why, and its next attempt's log follows on; it is not a decline. A job whose runner the hub has not heard from for 3 minutes (claims, 30-second heartbeats, logs) is handed back the same way, rather than running on until its time limit and ending as lost. A job handed back more than 5 times ends as an error saying so. Migration 0094 adds `flow_jobs.handed_back_ms` and `handbacks`.
7 8
## 0.9.2 (2026-10-25)
8 9
9 10
- **`GET /api/v1/user/proposals?landable=true` (#311):** also lists the open proposals the caller could land now (in repositories they can write to, with no blockers, as each proposal's `landable` says), for the Android app to say when there is something to land. Only when asked for, since it works out each one's projected state.
…
311 unchanged lines
modified crates/hub/src/api/mod.rs +1 −0
…
70 unchanged lines
71 71
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
72 72
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
73 73
        .route("/flow/jobs/{id}/decline", post(crate::flow::decline))
74 +
        .route("/flow/jobs/{id}/release", post(crate::flow::release))
74 75
        .route(
75 76
            "/flow/jobs/{id}/artifacts/{name}",
76 77
            post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))),
…
2974 unchanged lines
modified crates/hub/src/db/flow.rs +59 −2
…
168 unchanged lines
169 169
    pub queued_ms: Option<i64>,
170 170
    #[sqlx(default)]
171 171
    pub ready_ms: Option<i64>,
172 +
    /// When a runner last handed it back, drained or with its lease lapsed
173 +
    /// (oxygenflow#76, migration 0094), and how many times: such a job is
174 +
    /// queued ahead of new work.
175 +
    #[sqlx(default)]
176 +
    pub handed_back_ms: Option<i64>,
177 +
    #[sqlx(default)]
178 +
    pub handbacks: i64,
172 179
}
173 180
174 181
impl Job {
…
547 unchanged lines
722 729
         WHERE j.status = 'queued' AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \
723 730
         AND ($1 = '' OR rep.owner = $2) AND ($3 IS NULL OR r.repo_id = $4) \
724 731
         AND NOT ($5 = '' AND $6 IS NULL AND rep.flow = 'own') \
725 -
         ORDER BY {PRIORITY_RANK}, ROW_NUMBER() OVER (PARTITION BY r.repo_id ORDER BY j.run_id, j.depth, j.id) \
732 +
         ORDER BY CASE WHEN j.handed_back_ms IS NULL THEN 1 ELSE 0 END, {PRIORITY_RANK}, \
733 +
           ROW_NUMBER() OVER (PARTITION BY r.repo_id ORDER BY j.run_id, j.depth, j.id) \
726 734
           + (SELECT COUNT(*) FROM flow_jobs x JOIN flow_runs xr ON xr.id = x.run_id WHERE x.status = 'running' AND xr.repo_id = r.repo_id), \
727 735
           j.run_id, j.depth, j.id LIMIT 200"
728 736
    ))
…
138 unchanged lines
867 875
            .await?;
868 876
    tx.commit().await?;
869 877
    Ok(Some(all))
878 +
}
879 +
880 +
/// Job `job_id`, running on runner `runner_id` (any runner when `None`),
881 +
/// goes back to the queue ahead of new work (oxygenflow#76): drained, or
882 +
/// its runner's lease lapsed. Its log is kept and its next attempt's
883 +
/// follows on. Returns how many times it has now been handed back, or
884 +
/// `None` when it was not running there.
885 +
pub async fn hand_back(pool: &Pool, job_id: i64, runner_id: Option<i64>) -> sqlx::Result<Option<i64>> {
886 +
    let mut tx = super::begin_write(pool).await?;
887 +
    let now = now_ms();
888 +
    let back = sqlx::query(
889 +
        "UPDATE flow_jobs SET status = 'queued', runner = '', token_hash = '', started_ms = NULL, runner_id = NULL, \
890 +
         handed_back_ms = $1, handbacks = handbacks + 1, queued_ms = $2, ready_ms = NULL, placed = '' \
891 +
         WHERE id = $3 AND status = 'running' AND ($4 IS NULL OR runner_id = $5)",
892 +
    )
893 +
    .bind(now)
894 +
    .bind(now)
895 +
    .bind(job_id)
896 +
    .bind(runner_id)
897 +
    .bind(runner_id)
898 +
    .execute(&mut *tx)
899 +
    .await?;
900 +
    if back.rows_affected() != 1 {
901 +
        tx.rollback().await?;
902 +
        return Ok(None);
903 +
    }
904 +
    let n: i64 = sqlx::query_scalar("SELECT handbacks FROM flow_jobs WHERE id = $1").bind(job_id).fetch_one(&mut *tx).await?;
905 +
    tx.commit().await?;
906 +
    Ok(Some(n))
907 +
}
908 +
909 +
/// Jobs running on a runner the hub has not heard from since `before`
910 +
/// (claims, heartbeats, logs): their leases lapsed (oxygenflow#76).
911 +
pub async fn running_on_silent_runners(pool: &Pool, before: i64) -> sqlx::Result<Vec<(Job, String)>> {
912 +
    let rows: Vec<(i64, String)> = sqlx::query_as(
913 +
        "SELECT j.id, r.name FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id \
914 +
         WHERE j.status = 'running' AND r.last_seen_ms < $1 ORDER BY j.id",
915 +
    )
916 +
    .bind(before)
917 +
    .fetch_all(pool)
918 +
    .await?;
919 +
    let mut out = Vec::new();
920 +
    for (id, runner) in rows {
921 +
        if let Some(j) = job(pool, id).await? {
922 +
            out.push((j, runner));
923 +
        }
924 +
    }
925 +
    Ok(out)
870 926
}
871 927
872 928
/// The declines of job `job_id`, oldest first.
…
320 unchanged lines
1193 1249
             exit_code = NULL, error = '', receipt = '', receipt_hash = '', log_count = 0, also_read = '', receipt_attest = '',
1194 1250
             receipt_signer = '', receipt_note = '', runner_id = NULL, waiting = '', placed = '', peak_disk_mib = NULL,
1195 1251
             peak_memory_mib = NULL, approval = CASE WHEN approval = '' THEN '' ELSE 'waiting' END, approved_by = '',
1196 -
             approved_ms = NULL, approval_seal = '', approved_via = '', queued_ms = $2, ready_ms = NULL WHERE id = $1",
1252 +
             approved_ms = NULL, approval_seal = '', approved_via = '', queued_ms = $2, ready_ms = NULL, handed_back_ms = NULL,
1253 +
             handbacks = 0 WHERE id = $1",
1197 1254
        )
1198 1255
        .bind(id)
1199 1256
        .bind(now_ms())
…
236 unchanged lines
modified crates/hub/src/flow.rs +75 −0
…
1326 unchanged lines
1327 1327
/// A job handed back by this many different runners is unplaceable.
1328 1328
pub const UNPLACEABLE_AFTER: usize = 3;
1329 1329
1330 +
/// How long a runner holds a job without the hub hearing from it (claims,
1331 +
/// heartbeats every 30 s, logs) before the job is handed back
1332 +
/// (oxygenflow#76).
1333 +
pub const LEASE_MS: i64 = 3 * 60_000;
1334 +
1335 +
/// A job handed back more times than this ends as an error rather than
1336 +
/// bounce from runner to runner.
1337 +
pub const MAX_HANDBACKS: i64 = 5;
1338 +
1339 +
/// Hand job `id` back to the queue, ahead of new work (oxygenflow#76): its
1340 +
/// runner drained before leaving (`runner` is it), or its lease lapsed
1341 +
/// (`None`). The log says why, and the next attempt's follows on. False
1342 +
/// when the job was not running there.
1343 +
pub async fn hand_back_job(app: &Shared, id: i64, runner: Option<i64>, why: &str) -> sqlx::Result<bool> {
1344 +
    let Some(before) = fdb::job(&app.db, id).await? else { return Ok(false) };
1345 +
    let Some(n) = fdb::hand_back(&app.db, id, runner).await? else { return Ok(false) };
1346 +
    let t = before.started_ms.map(|s| (db::now_ms() - s).max(0) as u64).unwrap_or(0);
1347 +
    let record = flow_core::api::Record { t, stream: "flow".into(), text: format!("── {why}; queued again ahead of new work") };
1348 +
    fdb::append_log(&app.db, id, &[record], MAX_RECORDS as i64).await.ok();
1349 +
    tracing::info!("flow: job {id} handed back ({n}): {why}");
1350 +
    if n > MAX_HANDBACKS {
1351 +
        let error = format!("handed back {n} times; the last: {why}");
1352 +
        let changed =
1353 +
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
1354 +
                .await?;
1355 +
        report_changed(app, before.run_id, changed).await?;
1356 +
    } else if let Some(run) = fdb::run_by_id(&app.db, before.run_id).await? {
1357 +
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
1358 +
            let summary = format!("{why}; queued again ahead of new work");
1359 +
            report_check(app, &row, &run.state, run.number, &before.name, "queued", &summary).await;
1360 +
        }
1361 +
    }
1362 +
    Ok(true)
1363 +
}
1364 +
1365 +
/// Hand back the jobs of runners not heard from for [`LEASE_MS`]: a phone
1366 +
/// that lost every network, a host that died (oxygenflow#76).
1367 +
pub async fn requeue_lapsed(app: &Shared) -> sqlx::Result<usize> {
1368 +
    let lapsed = fdb::running_on_silent_runners(&app.db, db::now_ms() - LEASE_MS).await?;
1369 +
    let mut n = 0;
1370 +
    for (job, runner) in lapsed {
1371 +
        let why = format!("the runner {runner} has not been heard from for {} minutes", LEASE_MS / 60_000);
1372 +
        if hand_back_job(app, job.id, None, &why).await? {
1373 +
            n += 1;
1374 +
        }
1375 +
    }
1376 +
    Ok(n)
1377 +
}
1378 +
1379 +
/// `POST /api/v1/flow/jobs/{id}/release` (oxygenflow#76): a draining
1380 +
/// runner hands back a job it holds, as it leaves (off the allowed network,
1381 +
/// a restart, a spot reclaim). Not a decline: the runner is not held from
1382 +
/// the job and it does not count towards unplaceable.
1383 +
pub async fn release(
1384 +
    State(app): State<Shared>,
1385 +
    headers: HeaderMap,
1386 +
    Path(id): Path<i64>,
1387 +
    Json(d): Json<flow_core::api::Decline>,
1388 +
) -> Result<StatusCode, ApiError> {
1389 +
    let me = runner_auth(&app, &headers).await?;
1390 +
    db::runners::seen(&app.db, me.id, None, None).await?;
1391 +
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
1392 +
    let why = match reason.trim() {
1393 +
        "" => format!("handed back by {}", me.name),
1394 +
        r => format!("handed back by {}: {r}", me.name),
1395 +
    };
1396 +
    if !hand_back_job(&app, id, Some(me.id), &why).await? {
1397 +
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
1398 +
    }
1399 +
    Ok(StatusCode::NO_CONTENT)
1400 +
}
1401 +
1330 1402
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
1331 1403
/// runner will not run a job it was handed. The job is queued again and
1332 1404
/// not offered to that runner while its profile stays the same (and for
…
1235 unchanged lines
2568 2640
pub async fn reaper(app: Shared) {
2569 2641
    loop {
2570 2642
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2643 +
        if let Err(e) = requeue_lapsed(&app).await {
2644 +
            tracing::warn!("flow: looking for lapsed leases: {e}");
2645 +
        }
2571 2646
        if let Err(e) = reap_lost_jobs(&app).await {
2572 2647
            tracing::warn!("flow: looking for lost jobs: {e}");
2573 2648
        }
…
279 unchanged lines
modified crates/hub/tests/flow.rs +73 −0
…
2619 unchanged lines
2620 2620
    assert_eq!(r.tier_list(), vec!["microvm"]);
2621 2621
}
2622 2622
2623 +
/// oxygenflow#76, #312: a draining runner hands back a job it holds. It is
2624 +
/// queued again ahead of new work (even a job of lower priority), its log
2625 +
/// says why and goes on with the next attempt, and it is no decline. A
2626 +
/// runner the hub stops hearing from has its jobs handed back the same way.
2627 +
#[tokio::test(flavor = "multi_thread")]
2628 +
async fn a_drained_job_goes_back_ahead_of_new_work() {
2629 +
    let h = hub().await;
2630 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2631 +
    create(&h.app, "andy", "drains").await;
2632 +
    let url = format!("{}/andy/drains", h.base);
2633 +
    let work = tempfile::tempdir().unwrap();
2634 +
    let c = client(&url, work.path());
2635 +
    let bulk = "job \"bulk\" {\n    sandbox \"alpine\"\n    priority \"low\"\n    exec \"true\"\n}\n";
2636 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(bulk));
2637 +
    write(work.path(), "test.sh", "echo ok\n");
2638 +
    c.commit("Add a pipeline", &[]).unwrap();
2639 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2640 +
    runs_of(&h.app, "andy", "drains").await;
2641 +
    let claim_as = |token: &'static str, name: &'static str| {
2642 +
        let app = h.app.clone();
2643 +
        async move {
2644 +
            let body = serde_json::json!({ "runner": name, "runtimes": ["microvm"] }).to_string();
2645 +
            let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some(token), Some(body)).await;
2646 +
            (st == StatusCode::OK).then(|| serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap())
2647 +
        }
2648 +
    };
2649 +
    let release = |token: &'static str, id: i64, reason: &'static str| {
2650 +
        let app = h.app.clone();
2651 +
        async move {
2652 +
            let body = serde_json::json!({ "reason": reason }).to_string();
2653 +
            call(&app, "POST", &format!("/api/v1/flow/jobs/{id}/release"), Some(token), Some(body)).await.0
2654 +
        }
2655 +
    };
2656 +
    let name_of = |id: i64| {
2657 +
        let app = h.app.clone();
2658 +
        async move { db::flow::job(&app.db, id).await.unwrap().unwrap().name }
2659 +
    };
2660 +
    let build = claim_as("runner-secret", "earth-1").await.unwrap();
2661 +
    let lint = claim_as("runner-two", "plex-1").await.unwrap();
2662 +
    let low = claim_as("runner-three", "phone").await.unwrap();
2663 +
    assert_eq!((name_of(build).await, name_of(lint).await, name_of(low).await), ("build".into(), "lint".into(), "bulk".into()));
2664 +
    // The low-priority job logs a line, then its runner leaves.
2665 +
    let line = serde_json::json!({ "records": [{ "t": 1200, "stream": "stdout", "text": "step 1 of 30" }] }).to_string();
2666 +
    call(&h.app, "POST", &format!("/api/v1/flow/jobs/{low}/log"), Some("runner-three"), Some(line)).await;
2667 +
    assert_eq!(finish(&h.app, build, serde_json::json!({ "exit_code": 0, "duration_ms": 700 })).await, StatusCode::NO_CONTENT);
2668 +
    assert_eq!(release("runner-two", low, "off Wi-Fi").await, StatusCode::CONFLICT, "only the runner that holds it");
2669 +
    assert_eq!(release("runner-three", low, "off Wi-Fi").await, StatusCode::NO_CONTENT);
2670 +
    let j = db::flow::job(&h.app.db, low).await.unwrap().unwrap();
2671 +
    assert_eq!((j.status.as_str(), j.runner.as_str(), j.handbacks), ("queued", "", 1));
2672 +
    let log: Vec<String> = db::flow::log(&h.app.db, low, 0, 50).await.unwrap().into_iter().map(|l| l.text).collect();
2673 +
    assert_eq!(log, ["step 1 of 30", "── handed back by phone: off Wi-Fi; queued again ahead of new work"]);
2674 +
    assert!(db::flow::declines_of_job(&h.app.db, low).await.unwrap().is_empty(), "not a decline");
2675 +
    // `test` became ready when `build` passed, and is of higher priority:
2676 +
    // the handed-back job still goes first, to any runner, its own too.
2677 +
    assert_eq!(claim_as("runner-three", "phone").await, Some(low));
2678 +
    assert_eq!(claim_as("runner-secret", "earth-1").await.map(|id| id != low), Some(true), "then new work");
2679 +
2680 +
    // The phone goes quiet: once its lease lapses the job is handed back.
2681 +
    let phone = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "phone").unwrap();
2682 +
    sqlx::query("UPDATE flow_runners SET last_seen_ms = $1 WHERE id = $2")
2683 +
        .bind(db::now_ms() - oxygenhub::flow::LEASE_MS - 1000)
2684 +
        .bind(phone.id)
2685 +
        .execute(&h.app.db)
2686 +
        .await
2687 +
        .unwrap();
2688 +
    assert_eq!(oxygenhub::flow::requeue_lapsed(&h.app).await.unwrap(), 1);
2689 +
    let j = db::flow::job(&h.app.db, low).await.unwrap().unwrap();
2690 +
    assert_eq!((j.status.as_str(), j.handbacks), ("queued", 2));
2691 +
    let log: Vec<String> = db::flow::log(&h.app.db, low, 0, 50).await.unwrap().into_iter().map(|l| l.text).collect();
2692 +
    assert_eq!(log.last().unwrap(), "── the runner phone has not been heard from for 3 minutes; queued again ahead of new work");
2693 +
    assert_eq!(claim_as("runner-two", "plex-1").await, Some(low), "another runner takes it");
2694 +
}
2695 +
2623 2696
/// oxygenhub#113: a job's artifact named `coverage` (LCOV) is read when
2624 2697
/// it is uploaded. The job's log says what it found; the Coverage tab,
2625 2698
/// the file view, the badge and the run page show it.
…
3815 unchanged lines