OXYGENHUB

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