Expediting a run moves the queue at once (closes #291) 794ff94f194a
Expediting places every queued job again straight away, runs whose jobs' notes change update live (the placement pass publishes them too), and a job an expedited run moved down says which run went before it.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0100a7b0faae
- When
- · 2026-10-24 22:45 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
794ff94f194a4de38bbbd80498747a270cf8ced1f95e4decc09be5cff5fca18a- Depends on
- 107eaba52c9d 42fa95540468 4d2e44d79b03 58cbf42447bc 67040b158ae2 92a797b20912 a7e6e8e6094c b5668108487f ba0a55bb5b3c
- Depended on by
- b1860d6090ec 456f4197ade3
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 10 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Expediting a run moves the queue at once (#291):** a run's jobs kept the place they had ("9 of 9 jobs come before") until the next placement pass and some other change on the page. Expediting now places every queued job again straight away, every run whose jobs' notes changed updates live, and a job an expedited run moved down says so ("2 ready jobs come first (after expedited run andy/demo #12)"). |
|
| 7 | 8 | - **Create and withdraw tags on the website (#295, #301):** a writer creates a tag on the Tags page (or `oh tag create`, `POST …/tags`), signed by the hub as their delegate, under the policy, and its tag jobs run; a maintainer withdraws one there (or `oh tag delete`, `DELETE …/tags/{tag}`, or `oxy push --delete tags/NAME`) with a signed withdrawal, never while a release uses it. The withdrawal is logged, audited and listed on the page with why, and clones drop the tag on their next fetch. Tags never move, and a withdrawn name is not used again. Pins oxygen with tag withdrawal and with keyless grants that survive their owner's key rotation (oxygen#135). |
|
| 8 | 9 | - **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant. |
|
| 9 | 10 | - **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all. |
|
| … | 287 unchanged lines |
modified crates/hub/src/flow.rs +36 −2
| … | 1502 unchanged lines |
||
| 1503 | 1503 | declines: &[fdb::DeclineRow], |
|
| 1504 | 1504 | running: &std::collections::HashMap<i64, i64>, |
|
| 1505 | 1505 | ahead: usize, |
|
| 1506 | + | behind_urgent: &[String], |
|
| 1506 | 1507 | ) -> String { |
|
| 1507 | 1508 | let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 }; |
|
| 1508 | 1509 | let mut busy = Vec::new(); |
|
| … | 14 unchanged lines |
||
| 1523 | 1524 | parts.push(format!("the runners that can take it are busy: {}", busy.join("; "))); |
|
| 1524 | 1525 | } |
|
| 1525 | 1526 | if ahead > 0 { |
|
| 1526 | - | parts.push(format!("{ahead} ready {} first", if ahead == 1 { "job comes" } else { "jobs come" })); |
|
| 1527 | + | let after = match behind_urgent { |
|
| 1528 | + | [] => String::new(), |
|
| 1529 | + | [one] => format!(" (after expedited run {one})"), |
|
| 1530 | + | many => format!(" (after expedited runs {})", many.join(", ")), |
|
| 1531 | + | }; |
|
| 1532 | + | parts.push(format!("{ahead} ready {} first{after}", if ahead == 1 { "job comes" } else { "jobs come" })); |
|
| 1527 | 1533 | } |
|
| 1528 | 1534 | parts.join("; ") |
|
| 1529 | 1535 | } |
|
| … | 101 unchanged lines |
||
| 1631 | 1637 | let running = fdb::running_by_runner(&app.db).await?; |
|
| 1632 | 1638 | // Claim order, roughly: a better rank first, then the older job. |
|
| 1633 | 1639 | let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect(); |
|
| 1640 | + | let run_of: std::collections::HashMap<i64, i64> = ready.iter().map(|(j, _, _)| (j.id, j.run_id)).collect(); |
|
| 1641 | + | // Expedited runs (rank 0) by name, for the jobs they moved down (#291). |
|
| 1642 | + | let mut urgent: std::collections::BTreeMap<i64, String> = std::collections::BTreeMap::new(); |
|
| 1643 | + | for (j, _, _) in ready.iter().filter(|(j, _, _)| ranks.get(&j.id) == Some(&0)) { |
|
| 1644 | + | if urgent.contains_key(&j.run_id) { |
|
| 1645 | + | continue; |
|
| 1646 | + | } |
|
| 1647 | + | if let Some(run) = fdb::run_by_id(&app.db, j.run_id).await? { |
|
| 1648 | + | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
|
| 1649 | + | urgent.insert(j.run_id, format!("{}/{} #{}", row.owner, row.name, run.number)); |
|
| 1650 | + | } |
|
| 1651 | + | } |
|
| 1652 | + | } |
|
| 1653 | + | // Runs whose waiting notes changed: their pages hear of it now. |
|
| 1654 | + | let mut changed = std::collections::BTreeSet::new(); |
|
| 1634 | 1655 | for (job, owner, repo_id) in ready { |
|
| 1635 | 1656 | let declines = fdb::declines_of_job(&app.db, job.id).await?; |
|
| 1636 | 1657 | let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held); |
|
| 1637 | 1658 | if why.is_empty() { |
|
| 1638 | 1659 | let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id); |
|
| 1639 | 1660 | let ahead = order.iter().filter(|o| **o < mine).count(); |
|
| 1640 | - | why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead); |
|
| 1661 | + | let mut behind: Vec<String> = Vec::new(); |
|
| 1662 | + | if mine.0 > 0 { |
|
| 1663 | + | for o in order.iter().filter(|o| o.0 == 0 && **o < mine) { |
|
| 1664 | + | let Some(r) = run_of.get(&o.1).filter(|r| **r != job.run_id) else { continue }; |
|
| 1665 | + | if let Some(label) = urgent.get(r).filter(|l| !behind.contains(l)) { |
|
| 1666 | + | behind.push(label.clone()); |
|
| 1667 | + | } |
|
| 1668 | + | } |
|
| 1669 | + | } |
|
| 1670 | + | why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead, &behind); |
|
| 1641 | 1671 | } |
|
| 1642 | 1672 | if why == job.waiting { |
|
| 1643 | 1673 | continue; |
|
| 1644 | 1674 | } |
|
| 1645 | 1675 | fdb::set_waiting(&app.db, job.id, &why).await?; |
|
| 1676 | + | changed.insert(job.run_id); |
|
| 1646 | 1677 | // The job's check says it too, so a proposal shows why it is stuck |
|
| 1647 | 1678 | // (oxygenhub#28); only when the reason changes in kind, not in its |
|
| 1648 | 1679 | // counts (#259: a queue position moves every pass, and each check |
|
| … | 6 unchanged lines |
||
| 1655 | 1686 | let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() }; |
|
| 1656 | 1687 | report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await; |
|
| 1657 | 1688 | } |
|
| 1689 | + | } |
|
| 1690 | + | for run_id in changed { |
|
| 1691 | + | app.bus.live(&crate::events::topic::run(run_id), "jobs"); |
|
| 1658 | 1692 | } |
|
| 1659 | 1693 | Ok(()) |
|
| 1660 | 1694 | } |
|
| … | 1158 unchanged lines |
modified crates/hub/src/web/flow.rs +7 −0
| … | 289 unchanged lines |
||
| 290 | 290 | db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}")) |
|
| 291 | 291 | .await |
|
| 292 | 292 | .ok(); |
|
| 293 | + | // Every queued job's place changes now, not at the next pass |
|
| 294 | + | // (#291): this run's page and the pages of the runs it moved down |
|
| 295 | + | // hear of it. |
|
| 296 | + | if let Err(e) = crate::flow::explain_waits(&app).await { |
|
| 297 | + | tracing::warn!("flow: placing after an expedite: {e}"); |
|
| 298 | + | } |
|
| 299 | + | app.bus.live(&crate::events::topic::run(run.id), "jobs"); |
|
| 293 | 300 | } |
|
| 294 | 301 | Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()) |
|
| 295 | 302 | } |
|
| … | 706 unchanged lines |
modified crates/hub/tests/flow.rs +73 −0
| … | 2875 unchanged lines |
||
| 2876 | 2876 | assert!(build.waiting.contains("earth-9 has a free slot (1 of 2) but not the memory or disk for it now"), "{}", build.waiting); |
|
| 2877 | 2877 | } |
|
| 2878 | 2878 | ||
| 2879 | + | /// #291: expediting a run places the queue again at once, and a job it |
|
| 2880 | + | /// moved down says which expedited run went before it. |
|
| 2881 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2882 | + | async fn an_expedited_run_moves_the_queue_at_once() { |
|
| 2883 | + | let h = hub().await; |
|
| 2884 | + | for name in ["first", "urgent"] { |
|
| 2885 | + | create(&h.app, "andy", name).await; |
|
| 2886 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2887 | + | let c = client(&format!("{}/andy/{name}", h.base), work.path()); |
|
| 2888 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 2889 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 2890 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 2891 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2892 | + | // In order: the second run is the newer. |
|
| 2893 | + | assert_eq!(runs_of(&h.app, "andy", name).await.len(), 1); |
|
| 2894 | + | } |
|
| 2895 | + | // One runner holding the image, busy with something else. |
|
| 2896 | + | let r = db::runners::configured(&h.app.db, &blake3::hash(b"busy").to_hex(), "earth-9").await.unwrap(); |
|
| 2897 | + | db::runners::seen(&h.app.db, r.id, None, None).await.unwrap(); |
|
| 2898 | + | db::runners::set_offer(&h.app.db, r.id, &["microvm".to_string()], Some(&[ROOTFS.to_string()]), false, "p").await.unwrap(); |
|
| 2899 | + | let first = runs_of(&h.app, "andy", "first").await.remove(0); |
|
| 2900 | + | sqlx::query( |
|
| 2901 | + | "UPDATE flow_jobs SET status = 'running', runner_id = $1, runner = 'earth-9', started_ms = $2 WHERE run_id = $3 AND name = 'lint'", |
|
| 2902 | + | ) |
|
| 2903 | + | .bind(r.id) |
|
| 2904 | + | .bind(db::now_ms()) |
|
| 2905 | + | .bind(first.id) |
|
| 2906 | + | .execute(&h.app.db) |
|
| 2907 | + | .await |
|
| 2908 | + | .unwrap(); |
|
| 2909 | + | oxygenhub::flow::explain_waits(&h.app).await.unwrap(); |
|
| 2910 | + | let db = h.app.db.clone(); |
|
| 2911 | + | let build = |run: i64| { |
|
| 2912 | + | let db = db.clone(); |
|
| 2913 | + | async move { db::flow::jobs(&db, run).await.unwrap().into_iter().find(|j| j.name == "build").unwrap() } |
|
| 2914 | + | }; |
|
| 2915 | + | let urgent = runs_of(&h.app, "andy", "urgent").await.remove(0); |
|
| 2916 | + | assert!(build(urgent.id).await.waiting.contains("1 ready job comes first"), "{}", build(urgent.id).await.waiting); |
|
| 2917 | + | ||
| 2918 | + | // Expedited from its page: the places change before any pass runs. |
|
| 2919 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 2920 | + | .oneshot( |
|
| 2921 | + | Request::post("/register") |
|
| 2922 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 2923 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 2924 | + | .unwrap(), |
|
| 2925 | + | ) |
|
| 2926 | + | .await |
|
| 2927 | + | .unwrap(); |
|
| 2928 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 2929 | + | let page = oxygenhub::router(h.app.clone()) |
|
| 2930 | + | .oneshot(Request::get("/andy/urgent/flow/1").header(header::COOKIE, cookie.clone()).body(Body::empty()).unwrap()) |
|
| 2931 | + | .await |
|
| 2932 | + | .unwrap(); |
|
| 2933 | + | let page = String::from_utf8_lossy(&page.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 2934 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 2935 | + | let csrf = page[i..].split('"').next().unwrap().to_string(); |
|
| 2936 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 2937 | + | .oneshot( |
|
| 2938 | + | Request::post("/andy/urgent/flow/1/expedite") |
|
| 2939 | + | .header(header::COOKIE, cookie.clone()) |
|
| 2940 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 2941 | + | .body(Body::from(format!("_csrf={csrf}&reason=incident"))) |
|
| 2942 | + | .unwrap(), |
|
| 2943 | + | ) |
|
| 2944 | + | .await |
|
| 2945 | + | .unwrap(); |
|
| 2946 | + | assert!(res.status().is_redirection(), "{}", res.status()); |
|
| 2947 | + | assert!(!build(urgent.id).await.waiting.contains("come"), "first now: {}", build(urgent.id).await.waiting); |
|
| 2948 | + | let w = build(first.id).await.waiting; |
|
| 2949 | + | assert!(w.contains("2 ready jobs come first (after expedited run andy/urgent #1)"), "{w}"); |
|
| 2950 | + | } |
|
| 2951 | + | ||
| 2879 | 2952 | async fn put_bytes(app: &Shared, path: &str, bearer: &str, bytes: Vec<u8>) -> (StatusCode, String) { |
|
| 2880 | 2953 | let req = Request::put(path) |
|
| 2881 | 2954 | .header(header::AUTHORIZATION, format!("Bearer {bearer}")) |
|
| … | 3455 unchanged lines |