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 |