The hub ends jobs no runner reports, past the time limit (refs #71) 070a7b64b368
A runner stops a job at the time limit and reports it within moments, so a job still running five minutes past the limit is one the runner no longer reports: it restarted, or lost the hub as the job ended (Earth's reset stranded five). reaper() looks once a minute and ends each as an error with the reason through the same finish path a runner's report takes: dependents skipped, the run settled, the checks reported. A test claims a job, backdates it and reaps it.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 00:31 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
070a7b64b368f6bf177d795fecfbc60476f745c880249c5312ac28d8f4024c56- Depends on
- 18e381ea88c2 42fa95540468 749d9a5fcd9f a0d4bbfbedd1 b5668108487f b64e2537d702 c4c87f481d05 cc19697f8871 d23464c0ac60 d98a93c10a0d
- Depended on by
- 42cd46c9b09e ab4df6aae179 5588bb3631d2 e57765dee61a 965b950f9b96 3b1bc27fe805 5d90e0e045e2 7e01a7665aa5 dc59d1b56c3d 417184c73f4e bff9106edcba 4a4400c5812d a7e6e8e6094c 745d225de344 348f8c4baccf 5dead1f4f26d eef677c48cd1 b0612e7dce30
- 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
- 7 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 | + | - **Lost jobs end by themselves (#71).** A job still running five minutes past the job time limit is one its runner no longer reports (it restarted, or lost the hub as the job ended). The hub now looks for those once a minute and ends them as errors with the reason, skipping the jobs that depended on them and settling the run, so a runner is not shown busy with jobs it is not running. Earth's reset stranded five such jobs, closed by hand. |
|
| 8 | + | ||
| 7 | 9 | - **A GitHub-like look, now the default (#74).** Primer colours in light and dark, system fonts, rounded boxes, underline navigation, grey and green buttons, sentence case and pill labels, for readability and familiarity, light by default (the theme toggle and Settings → Appearance still offer dark and matching the system). The RetroPlay look stays available: `?look=classic` on any page keeps it for that browser, and `?look=github` returns to the default. |
|
| 8 | 10 | ||
| 9 | 11 | ## 0.2.0 (2026-10-01) |
|
| … | 17 unchanged lines |
modified crates/hub/src/db/flow.rs +8 −0
| … | 399 unchanged lines |
||
| 400 | 400 | .await |
|
| 401 | 401 | } |
|
| 402 | 402 | ||
| 403 | + | /// Jobs running since before `ms` (the oldest first). |
|
| 404 | + | pub async fn running_since_before(pool: &Pool, ms: i64) -> sqlx::Result<Vec<Job>> { |
|
| 405 | + | sqlx::query_as("SELECT * FROM flow_jobs WHERE status = 'running' AND COALESCE(started_ms, 0) < ? ORDER BY started_ms") |
|
| 406 | + | .bind(ms) |
|
| 407 | + | .fetch_all(pool) |
|
| 408 | + | .await |
|
| 409 | + | } |
|
| 410 | + | ||
| 403 | 411 | pub struct Outcome<'a> { |
|
| 404 | 412 | pub status: &'a str, |
|
| 405 | 413 | pub exit_code: Option<i64>, |
|
| … | 146 unchanged lines |
modified crates/hub/src/flow.rs +59 −9
| … | 494 unchanged lines |
||
| 495 | 495 | }; |
|
| 496 | 496 | fdb::set_receipt(&app.db, job.id, &attest, &signer, ¬e).await?; |
|
| 497 | 497 | } |
|
| 498 | - | for j in changed { |
|
| 499 | - | let summary = match j.status.as_str() { |
|
| 500 | - | "passed" => format!("passed in {}", duration(&j)), |
|
| 501 | - | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 502 | - | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 503 | - | _ => format!("could not run: {}", j.error), |
|
| 504 | - | }; |
|
| 505 | - | report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| 506 | - | } |
|
| 507 | 498 | } |
|
| 508 | 499 | } |
|
| 500 | + | report_changed(&app, job.run_id, changed).await?; |
|
| 509 | 501 | Ok(StatusCode::NO_CONTENT) |
|
| 502 | + | } |
|
| 503 | + | ||
| 504 | + | /// Report the checks of jobs whose status changed. |
|
| 505 | + | async fn report_changed(app: &Shared, run_id: i64, changed: Vec<fdb::Job>) -> sqlx::Result<()> { |
|
| 506 | + | let Some(run) = fdb::run_by_id(&app.db, run_id).await? else { |
|
| 507 | + | return Ok(()); |
|
| 508 | + | }; |
|
| 509 | + | let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else { |
|
| 510 | + | return Ok(()); |
|
| 511 | + | }; |
|
| 512 | + | for j in changed { |
|
| 513 | + | let summary = match j.status.as_str() { |
|
| 514 | + | "passed" => format!("passed in {}", duration(&j)), |
|
| 515 | + | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 516 | + | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 517 | + | _ => format!("could not run: {}", j.error), |
|
| 518 | + | }; |
|
| 519 | + | report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| 520 | + | } |
|
| 521 | + | Ok(()) |
|
| 522 | + | } |
|
| 523 | + | ||
| 524 | + | /// Grace past the job time limit before the hub gives a job up: a runner |
|
| 525 | + | /// stops a job at the limit and reports it within moments, so one still |
|
| 526 | + | /// running this long after is one the runner no longer reports (it was |
|
| 527 | + | /// restarted, or lost the hub while the job ended, oxygenhub#71). |
|
| 528 | + | const LOST_GRACE_MS: i64 = 5 * 60 * 1000; |
|
| 529 | + | ||
| 530 | + | /// End every job still running past the job time limit plus the grace: |
|
| 531 | + | /// it is marked an error with the reason, the jobs that depended on it are |
|
| 532 | + | /// skipped and its run settled, as when a runner reports an error. |
|
| 533 | + | pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> { |
|
| 534 | + | let limit_ms = app.config.flow.job_timeout_minutes.max(1) as i64 * 60_000 + LOST_GRACE_MS; |
|
| 535 | + | let lost = fdb::running_since_before(&app.db, db::now_ms() - limit_ms).await?; |
|
| 536 | + | let n = lost.len(); |
|
| 537 | + | for job in lost { |
|
| 538 | + | let error = format!( |
|
| 539 | + | "lost: still running {} minutes after it started, past the time limit; the runner{} stopped reporting it", |
|
| 540 | + | (db::now_ms() - job.started_ms.unwrap_or(0)) / 60_000, |
|
| 541 | + | if job.runner.is_empty() { String::new() } else { format!(" {}", job.runner) }, |
|
| 542 | + | ); |
|
| 543 | + | let changed = |
|
| 544 | + | fdb::finish(&app.db, job.id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
|
| 545 | + | .await?; |
|
| 546 | + | tracing::warn!("flow: job {} {error}", job.id); |
|
| 547 | + | report_changed(app, job.run_id, changed).await?; |
|
| 548 | + | } |
|
| 549 | + | Ok(n) |
|
| 550 | + | } |
|
| 551 | + | ||
| 552 | + | /// Look for lost jobs once a minute. |
|
| 553 | + | pub async fn reaper(app: Shared) { |
|
| 554 | + | loop { |
|
| 555 | + | tokio::time::sleep(std::time::Duration::from_secs(60)).await; |
|
| 556 | + | if let Err(e) = reap_lost_jobs(&app).await { |
|
| 557 | + | tracing::warn!("flow: looking for lost jobs: {e}"); |
|
| 558 | + | } |
|
| 559 | + | } |
|
| 510 | 560 | } |
|
| 511 | 561 | ||
| 512 | 562 | /// Check a runner's signed receipt for a job of `run` and keep it in the |
|
| … | 168 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 63 unchanged lines |
||
| 64 | 64 | }); |
|
| 65 | 65 | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| 66 | 66 | tokio::spawn(hooks::worker(app.clone())); |
|
| 67 | + | tokio::spawn(flow::reaper(app.clone())); |
|
| 67 | 68 | Ok(app) |
|
| 68 | 69 | } |
|
| 69 | 70 | ||
| … | 41 unchanged lines |
modified crates/hub/tests/flow.rs +37 −0
| … | 1328 unchanged lines |
||
| 1329 | 1329 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1330 | 1330 | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["job"]["name"], "cross"); |
|
| 1331 | 1331 | } |
|
| 1332 | + | ||
| 1333 | + | /// oxygenhub#71: a job its runner stopped reporting (restarted, or lost |
|
| 1334 | + | /// the hub as the job ended) is ended once it has run past the time limit |
|
| 1335 | + | /// and a grace: an error with the reason, its dependents skipped and its |
|
| 1336 | + | /// run settled, as if the runner had reported the error. |
|
| 1337 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1338 | + | async fn a_job_no_runner_reports_is_ended_after_the_time_limit() { |
|
| 1339 | + | let h = hub().await; |
|
| 1340 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 1341 | + | create(&h.app, "andy", "lost").await; |
|
| 1342 | + | let url = format!("{}/andy/lost", h.base); |
|
| 1343 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1344 | + | let c = client(&url, work.path()); |
|
| 1345 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1346 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1347 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1348 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1349 | + | assert_eq!(runs_of(&h.app, "andy", "lost").await.len(), 1); |
|
| 1350 | + | let job = claim(&h.app).await.expect("a job to claim"); |
|
| 1351 | + | let id = job["job_id"].as_i64().unwrap(); |
|
| 1352 | + | ||
| 1353 | + | // Running, but well within the limit: left alone. |
|
| 1354 | + | assert_eq!(oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap(), 0); |
|
| 1355 | + | // Started long ago and never reported: ended. |
|
| 1356 | + | let old = db::now_ms() - 24 * 3600 * 1000; |
|
| 1357 | + | sqlx::query("UPDATE flow_jobs SET started_ms = ? WHERE id = ?").bind(old).bind(id).execute(&h.app.db).await.unwrap(); |
|
| 1358 | + | assert_eq!(oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap(), 1); |
|
| 1359 | + | let j = db::flow::job(&h.app.db, id).await.unwrap().unwrap(); |
|
| 1360 | + | assert_eq!(j.status, "error"); |
|
| 1361 | + | assert!(j.error.starts_with("lost:"), "{}", j.error); |
|
| 1362 | + | let run = &runs_of(&h.app, "andy", "lost").await[0]; |
|
| 1363 | + | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| 1364 | + | assert!(jobs.iter().all(|j| j.status != "running"), "nothing left running"); |
|
| 1365 | + | // A late report, should one come, does not overwrite the ending. |
|
| 1366 | + | assert_ne!(finish(&h.app, id, serde_json::json!({ "exit_code": 0 })).await, StatusCode::NO_CONTENT); |
|
| 1367 | + | assert_eq!(db::flow::job(&h.app.db, id).await.unwrap().unwrap().status, "error"); |
|
| 1368 | + | } |