Flow: repositories take turns on the runners (fair share) 55673c650986
Jobs were offered strictly oldest first, so one repository's burst held everyone: on 2026-10-16 a 20 s OpenTofu plan waited over an hour behind game builds. A queued job's turn is now its place in its own repository's queue plus that repository's running jobs, lowest first, oldest first among equals: a repository with work running yields to one with none, and waiting repositories alternate. Admin > Runners' queue shows the same order. A test pushes two repositories and checks the claims alternate; it passes on SQLite and Postgres. Priorities and expediting are next. Refs #244
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 22:40 UTC
- Signature
- ✔ signed by Claude Code
- Hash
55673c650986366ebafeaa96f1a2174ad5136b26a3870f2a82087aca35dc4077- Depends on
- 417184c73f4e 42fa95540468 47e374020b59 5d90e0e045e2 74be97962ed8 76bc5a058351 a0d4bbfbedd1 b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368
- Depended on by
- 107eaba52c9d
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 12 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/db/flow.rs +12 −4
| … | 307 unchanged lines |
||
| 308 | 308 | "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox \ |
|
| 309 | 309 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \ |
|
| 310 | 310 | WHERE j.status IN ('queued', 'running') AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \ |
|
| 311 | - | ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id", |
|
| 311 | + | ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, \ |
|
| 312 | + | CASE j.status WHEN 'running' THEN 0 ELSE ROW_NUMBER() OVER (PARTITION BY r.repo_id, j.status ORDER BY j.run_id, j.depth, j.id) \ |
|
| 313 | + | + (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) END, \ |
|
| 314 | + | j.run_id, j.depth, j.id", |
|
| 312 | 315 | ) |
|
| 313 | 316 | .fetch_all(pool) |
|
| 314 | 317 | .await |
|
| … | 168 unchanged lines |
||
| 483 | 486 | claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default(), &[]).await |
|
| 484 | 487 | } |
|
| 485 | 488 | ||
| 486 | - | /// The oldest runnable job that fits in `room`. |
|
| 487 | 489 | /// The first queued job a runner with these tiers, this room and this |
|
| 488 | - | /// scope may take, now running under `runner`. A scoped runner never sees |
|
| 490 | + | /// scope may take, now running under `runner`. Repositories take turns |
|
| 491 | + | /// (fair share, oxygenhub#244): a job's turn is its place in its own |
|
| 492 | + | /// repository's queue plus that repository's running jobs, lowest first, |
|
| 493 | + | /// oldest first among equals, so one repository's burst can't hold up the |
|
| 494 | + | /// rest. A scoped runner never sees |
|
| 489 | 495 | /// another scope's jobs (oxygenhub#41). |
|
| 490 | 496 | pub async fn claim_within( |
|
| 491 | 497 | pool: &Pool, |
|
| … | 26 unchanged lines |
||
| 518 | 524 | WHERE j.status = 'queued' AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \ |
|
| 519 | 525 | AND ($1 = '' OR rep.owner = $2) AND ($3 IS NULL OR r.repo_id = $4) \ |
|
| 520 | 526 | AND NOT ($5 = '' AND $6 IS NULL AND rep.flow = 'own') \ |
|
| 521 | - | ORDER BY j.run_id, j.depth, j.id LIMIT 200", |
|
| 527 | + | ORDER BY ROW_NUMBER() OVER (PARTITION BY r.repo_id ORDER BY j.run_id, j.depth, j.id) \ |
|
| 528 | + | + (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), \ |
|
| 529 | + | j.run_id, j.depth, j.id LIMIT 200", |
|
| 522 | 530 | ) |
|
| 523 | 531 | .bind(&scope.owner) |
|
| 524 | 532 | .bind(&scope.owner) |
|
| … | 621 unchanged lines |
modified crates/hub/tests/flow.rs +34 −4
| … | 561 unchanged lines |
||
| 562 | 562 | } |
|
| 563 | 563 | let plain = runs_of(&h.app, "other", "plain").await; |
|
| 564 | 564 | assert_eq!(plain.len(), 1, "turned on"); |
|
| 565 | - | // With andy/first's jobs ahead, plain's run says where it stands. |
|
| 565 | + | // andy/first has a job running, so plain's goes next (repositories take |
|
| 566 | + | // turns, #244), and its run says where it stands. |
|
| 566 | 567 | let a = claim(&h.app).await.unwrap(); |
|
| 567 | 568 | assert_eq!(a["repo"], "andy/first"); |
|
| 568 | 569 | let (_, page) = call(&h.app, "GET", "/other/plain/flow/1", None, None).await; |
|
| 569 | - | assert!(page.contains("Waiting for a runner: 3rd in line") && page.contains("Running now: andy/first #1"), "{page}"); |
|
| 570 | + | assert!(page.contains("Waiting for a runner: 1st in line") && page.contains("Running now: andy/first #1"), "{page}"); |
|
| 570 | 571 | let (_, body) = call(&h.app, "GET", "/api/v1/repos/other/plain/flow/runs/1", None, None).await; |
|
| 571 | - | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["queue_position"], 3); |
|
| 572 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["queue_position"], 1); |
|
| 572 | 573 | let (_, list) = call(&h.app, "GET", "/other/plain/flow", None, None).await; |
|
| 573 | - | assert!(list.contains("queued, 3rd in line"), "{list}"); |
|
| 574 | + | assert!(list.contains("queued, 1st in line"), "{list}"); |
|
| 574 | 575 | // Runs are numbered per repository: this is plain's first, whatever |
|
| 575 | 576 | // other repositories ran before. |
|
| 576 | 577 | assert_eq!(plain[0].number, 1); |
|
| … | 3997 unchanged lines |
||
| 4574 | 4575 | let (_, page) = call(&h.app, "GET", &format!("/andy/infra/flow/{}", run.number), None, None).await; |
|
| 4575 | 4576 | assert!(page.contains("sealed with their key"), "the run page says so"); |
|
| 4576 | 4577 | } |
|
| 4578 | + | ||
| 4579 | + | /// Repositories take turns (oxygenhub#244): a repository with a job running |
|
| 4580 | + | /// waits behind one with none, so a burst can't hold up the rest. |
|
| 4581 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4582 | + | async fn repositories_take_turns_on_the_runners() { |
|
| 4583 | + | let h = hub().await; |
|
| 4584 | + | for name in ["busy", "quiet"] { |
|
| 4585 | + | create(&h.app, "andy", name).await; |
|
| 4586 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4587 | + | let c = client(&format!("{}/andy/{name}", h.base), work.path()); |
|
| 4588 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 4589 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 4590 | + | c.snapshot("test").unwrap(); |
|
| 4591 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 4592 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4593 | + | assert_eq!(runs_of(&h.app, "andy", name).await.len(), 1); |
|
| 4594 | + | } |
|
| 4595 | + | // busy pushed first: its first job goes first; then quiet's turn, though |
|
| 4596 | + | // busy's other independent job is older; then busy's again. |
|
| 4597 | + | let order: Vec<String> = { |
|
| 4598 | + | let mut out = Vec::new(); |
|
| 4599 | + | for _ in 0..3 { |
|
| 4600 | + | let c = claim(&h.app).await.expect("a job to claim"); |
|
| 4601 | + | out.push(c["repo"].as_str().unwrap().to_string()); |
|
| 4602 | + | } |
|
| 4603 | + | out |
|
| 4604 | + | }; |
|
| 4605 | + | assert_eq!(order, ["andy/busy", "andy/quiet", "andy/busy"]); |
|
| 4606 | + | } |
modified templates/admin_runners.html +1 −1
| … | 45 unchanged lines |
||
| 46 | 46 | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr> |
|
| 47 | 47 | {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %} |
|
| 48 | 48 | </table></div> |
|
| 49 | - | <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p> |
|
| 49 | + | <p class="small mute">Queued jobs are in the order runners claim them: repositories take turns, a repository with jobs running waiting behind those without; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p> |
|
| 50 | 50 | {% endif %} |
|
| 51 | 51 | </section> |
|
| 52 | 52 | <section class="panel"> |
|
| … | 10 unchanged lines |