OXYGENHUB

Admin runner settings: job time limits; each job runs under its own 5dead1f4f26d

Admin → Runners → Settings (a runners | settings switcher) sets the
default limit, the maximum a pipeline may ask for and the lost-job
grace, kept as settings over hub.toml. At claim the job's limit is its
timeout, else its sandbox's, else the default, capped at the maximum;
migration 0032 records it on the job, and the reaper gives up each job
past its own limit. Pins oxygenflow with timeout. Tests.

closes #89
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 21:16 UTC
Signature
✔ signed by Andy McCall
Hash
5dead1f4f26dfb40114ceed6ca31ee8e487ae3442cf1ffa1c9e75b64d3a89bc5
Depends on
070a7b64b368 19bb31ff1f5c 34f40eb4b205 380996db84e5 42fa95540468 6a1780558353 749d9a5fcd9f b5668108487f b6b7e4cf6b24 d23464c0ac60 d7ce41acf22c fd3db644df22
Depended on by
8501fd5815b9 737de0fac722 367f34b8e590 76f93eaefe45 107eaba52c9d 3b1bc27fe805 5d90e0e045e2 6ca0b971ff07 3556d08e17f9 7f5f6c056d8f 745d225de344 c889911bec12 0a56c5313bc0
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.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
16 atoms, 2 tree operations, 11 files

Browse the tree at this change

added crates/hub/migrations/0032_job_timeout.sql +4 −0
1 +
-- Each job's own time limit, fixed when it is claimed (oxygenflow#23,
2 +
-- oxygenhub#89): the pipeline's timeout, else the site default, capped at
3 +
-- the site maximum. 0 for jobs claimed before: the site default.
4 +
ALTER TABLE flow_jobs ADD COLUMN timeout_s INTEGER NOT NULL DEFAULT 0;
added templates/admin_runner_settings.html +27 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Runner settings · Administration{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div>
5 +
<div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/settings" class="on">settings</a></span></div>
6 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
7 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
8 +
<form class="card wide" method="post" action="/admin/runners/settings">
9 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
10 +
  <h2>Job time limits</h2>
11 +
  <p class="small mute">A pipeline sets a job's limit with <code>timeout "90m"</code> on the job or its sandbox. These apply to every repository on this hub, to jobs claimed from now on.</p>
12 +
  <label for="default_minutes">Default, for a job whose pipeline sets none (minutes)</label>
13 +
  <input type="number" id="default_minutes" name="default_minutes" min="1" max="1440" value="{{ default_m }}" required>
14 +
  <p class="small mute">hub.toml says {{ file_default_m }}; this page's value wins once saved.</p>
15 +
  <label for="max_minutes">Maximum a pipeline may ask for (minutes)</label>
16 +
  <input type="number" id="max_minutes" name="max_minutes" min="1" max="1440" value="{{ max_m }}" required>
17 +
  <p class="small mute">A longer <code>timeout</code> is cut to this.</p>
18 +
  <label for="grace_minutes">Lost after (minutes past a job's limit)</label>
19 +
  <input type="number" id="grace_minutes" name="grace_minutes" min="1" max="120" value="{{ grace_m }}" required>
20 +
  <p class="small mute">A runner stops a job at its limit and says so at once; a job still running this long after is one its runner no longer reports, and the hub ends it.</p>
21 +
  <button type="submit">Save</button>
22 +
</form>
23 +
<section class="panel">
24 +
  <h2>Repositories that run Flow</h2>
25 +
  {% if flow_repos.is_empty() %}<p class="mute">None listed in hub.toml; each repository's administrator switches Flow on in its Settings.</p>{% else %}<ul class="plain">{% for r in flow_repos %}<li><code>{{ r }}</code></li>{% endfor %}</ul><p class="small mute">From hub.toml's <code>[flow] repos</code>; others are switched on in each repository's Settings.</p>{% endif %}
26 +
</section>
27 +
{% endblock %}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Runner settings for the site, and a job's own time limit (#89, oxygenflow#23).** Admin → Runners → Settings sets the default job time limit, the most a pipeline may ask for, and how long past its limit a silent job is given up; saved in the database, over hub.toml's `job_timeout_minutes`. A job runs under its pipeline's `timeout` (on the job or its sandbox), capped at the maximum, else the default; the limit is recorded on the job when it is claimed, and the lost-job reaper uses each job's own.
8 +
7 9
- **A job's `scope` is honoured (#88).** A run a push starts leaves out each job whose `scope` globs none of the files the push's new changes touch (since the bookmark's previous run), unless a job that runs depends on it; a bookmark's first run, "Run pipeline" and reruns keep every job. A push that touches no job's business starts no run.
8 10
9 11
- **Modern form controls everywhere (#87).** Every text field, textarea and dropdown on the site is rounded and padded, rings in the accent colour on focus, and dropdowns draw their own chevron; checkboxes and radios take the accent colour. On issues and proposals, Projects is a gear menu like Labels and Reviewers instead of a dropdown and Add button.
…
49 unchanged lines
modified crates/hub/src/db/flow.rs +20 −0
…
416 unchanged lines
417 417
}
418 418
419 419
/// Jobs running since before `ms` (the oldest first).
420 +
/// Jobs still running at `now` past their own time limit (`default_s` for
421 +
/// one claimed without) plus `grace_ms`.
422 +
pub async fn running_past_limit(pool: &Pool, now: i64, default_s: u64, grace_ms: i64) -> sqlx::Result<Vec<Job>> {
423 +
    sqlx::query_as(
424 +
        "SELECT * FROM flow_jobs WHERE status = 'running' \
425 +
         AND COALESCE(started_ms, 0) + (CASE WHEN timeout_s > 0 THEN timeout_s ELSE ? END) * 1000 + ? < ? ORDER BY started_ms",
426 +
    )
427 +
    .bind(default_s as i64)
428 +
    .bind(grace_ms)
429 +
    .bind(now)
430 +
    .fetch_all(pool)
431 +
    .await
432 +
}
433 +
434 +
/// Record the limit a claimed job runs under.
435 +
pub async fn set_job_timeout(pool: &Pool, job_id: i64, timeout_s: u64) -> sqlx::Result<()> {
436 +
    sqlx::query("UPDATE flow_jobs SET timeout_s = ? WHERE id = ?").bind(timeout_s as i64).bind(job_id).execute(pool).await?;
437 +
    Ok(())
438 +
}
439 +
420 440
pub async fn running_since_before(pool: &Pool, ms: i64) -> sqlx::Result<Vec<Job>> {
421 441
    sqlx::query_as("SELECT * FROM flow_jobs WHERE status = 'running' AND COALESCE(started_ms, 0) < ? ORDER BY started_ms")
422 442
        .bind(ms)
…
158 unchanged lines
modified crates/hub/src/flow.rs +40 −11
…
386 unchanged lines
387 387
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
388 388
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
389 389
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
390 +
    // The job's limit: its own timeout, else its sandbox's, else the site
391 +
    // default, never past the site maximum; recorded for the reaper.
392 +
    let limits = limits(&app).await;
393 +
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
394 +
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
395 +
    let timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
396 +
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
390 397
    let claim = Claim {
391 398
        job_id: job.id,
392 399
        run_id: run.id,
…
2 unchanged lines
395 402
        token: secret,
396 403
        heads: run.head_list().into_iter().map(String::from).collect(),
397 404
        pipeline_hash: run.pipeline_hash.clone(),
398 -
        job: serde_json::from_str(&job.spec).map_err(bad)?,
399 -
        sandbox: serde_json::from_str(&job.sandbox).map_err(bad)?,
400 -
        timeout_s: app.config.flow.job_timeout_minutes.max(1) * 60,
405 +
        job: spec,
406 +
        sandbox,
407 +
        timeout_s,
401 408
        subrepos: vec![],
402 409
        state: run.state.clone(),
403 410
        bookmark: run.bookmark.clone(),
…
231 unchanged lines
635 642
    Ok(())
636 643
}
637 644
638 -
/// Grace past the job time limit before the hub gives a job up: a runner
639 -
/// stops a job at the limit and reports it within moments, so one still
640 -
/// running this long after is one the runner no longer reports (it was
641 -
/// restarted, or lost the hub while the job ended, oxygenhub#71).
642 -
const LOST_GRACE_MS: i64 = 5 * 60 * 1000;
645 +
/// The site's job time limits (oxygenhub#89): set on Admin → Runners →
646 +
/// Settings, else from `hub.toml`.
647 +
pub struct Limits {
648 +
    /// For a job whose pipeline names none.
649 +
    pub default_s: u64,
650 +
    /// The most a pipeline may ask for.
651 +
    pub max_s: u64,
652 +
    /// Grace past a job's limit before the hub gives it up: a runner stops
653 +
    /// a job at its limit and reports it within moments, so one still
654 +
    /// running this long after is one the runner no longer reports (it was
655 +
    /// restarted, or lost the hub while the job ended, oxygenhub#71).
656 +
    pub grace_ms: i64,
657 +
}
658 +
659 +
pub const SETTING_DEFAULT: &str = "flow.job_timeout_minutes";
660 +
pub const SETTING_MAX: &str = "flow.job_timeout_max_minutes";
661 +
pub const SETTING_GRACE: &str = "flow.lost_grace_minutes";
662 +
663 +
pub async fn limits(app: &Shared) -> Limits {
664 +
    let minutes = |key: &'static str| async move {
665 +
        db::accounts::setting(&app.db, key).await.ok().flatten().and_then(|v| v.parse::<u64>().ok()).filter(|m| *m > 0)
666 +
    };
667 +
    let default_m = minutes(SETTING_DEFAULT).await.unwrap_or(app.config.flow.job_timeout_minutes.max(1));
668 +
    let max_m = minutes(SETTING_MAX).await.unwrap_or(default_m.max(6 * 60)).max(default_m);
669 +
    let grace_m = minutes(SETTING_GRACE).await.unwrap_or(5);
670 +
    Limits { default_s: default_m * 60, max_s: max_m * 60, grace_ms: grace_m as i64 * 60_000 }
671 +
}
643 672
644 -
/// End every job still running past the job time limit plus the grace:
673 +
/// End every job still running past its time limit plus the grace:
645 674
/// it is marked an error with the reason, the jobs that depended on it are
646 675
/// skipped and its run settled, as when a runner reports an error.
647 676
pub async fn reap_lost_jobs(app: &Shared) -> sqlx::Result<usize> {
648 -
    let limit_ms = app.config.flow.job_timeout_minutes.max(1) as i64 * 60_000 + LOST_GRACE_MS;
649 -
    let lost = fdb::running_since_before(&app.db, db::now_ms() - limit_ms).await?;
677 +
    let limits = limits(app).await;
678 +
    let lost = fdb::running_past_limit(&app.db, db::now_ms(), limits.default_s, limits.grace_ms).await?;
650 679
    let n = lost.len();
651 680
    for job in lost {
652 681
        let error = format!(
…
210 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
97 unchanged lines
98 98
        .route("/admin/account", post(admin::account))
99 99
        .route("/admin/storage", get(admin::storage).post(admin::maintain))
100 100
        .route("/admin/runners", get(runners::list))
101 +
        .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings))
101 102
        .route("/admin/runners/token", post(runners::new_token))
102 103
        .route("/admin/runners/{id}", get(runners::show))
103 104
        .route("/admin/runners/{id}/state", post(runners::set_state))
…
518 unchanged lines
modified crates/hub/src/web/runners.rs +77 −0
…
275 unchanged lines
276 276
        _ => "–".into(),
277 277
    }
278 278
}
279 +
280 +
// ---------------------------------------------------------------- settings
281 +
282 +
#[derive(Template)]
283 +
#[template(path = "admin_runner_settings.html")]
284 +
pub struct SettingsPage {
285 +
    shell: Shell,
286 +
    default_m: u64,
287 +
    max_m: u64,
288 +
    grace_m: i64,
289 +
    /// What hub.toml says, shown beside a setting the page overrides.
290 +
    file_default_m: u64,
291 +
    flow_repos: Vec<String>,
292 +
    notice: Option<String>,
293 +
    error: Option<String>,
294 +
}
295 +
296 +
async fn settings_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Page<SettingsPage> {
297 +
    let l = crate::flow::limits(app).await;
298 +
    Page(SettingsPage {
299 +
        shell,
300 +
        default_m: l.default_s / 60,
301 +
        max_m: l.max_s / 60,
302 +
        grace_m: l.grace_ms / 60_000,
303 +
        file_default_m: app.config.flow.job_timeout_minutes,
304 +
        flow_repos: app.config.flow.repos.clone(),
305 +
        notice,
306 +
        error,
307 +
    })
308 +
}
309 +
310 +
/// `GET /admin/runners/settings` (oxygenhub#89).
311 +
pub async fn settings(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SettingsPage>, Error> {
312 +
    let shell = Shell::new(&app, "/admin/runners/settings", &viewer);
313 +
    require_admin(&viewer, &shell)?;
314 +
    Ok(settings_page(&app, shell, None, None).await)
315 +
}
316 +
317 +
#[derive(Deserialize)]
318 +
pub struct SettingsForm {
319 +
    #[serde(rename = "_csrf")]
320 +
    csrf_token: String,
321 +
    default_minutes: String,
322 +
    max_minutes: String,
323 +
    grace_minutes: String,
324 +
}
325 +
326 +
/// `POST /admin/runners/settings`: the site's job time limits.
327 +
pub async fn save_settings(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SettingsForm>) -> Result<Page<SettingsPage>, Error> {
328 +
    let shell = Shell::new(&app, "/admin/runners/settings", &viewer);
329 +
    let me = require_admin(&viewer, &shell)?;
330 +
    csrf(&viewer, &f.csrf_token, &shell)?;
331 +
    let num = |v: &str| v.trim().parse::<u64>().ok().filter(|n| *n > 0);
332 +
    let (Some(d), Some(m), Some(g)) = (num(&f.default_minutes), num(&f.max_minutes), num(&f.grace_minutes)) else {
333 +
        return Ok(settings_page(&app, shell, None, Some("Each limit is a whole number of minutes, at least 1.".into())).await);
334 +
    };
335 +
    if d > m {
336 +
        return Ok(settings_page(&app, shell, None, Some("The default cannot be longer than the maximum.".into())).await);
337 +
    }
338 +
    if m > 24 * 60 {
339 +
        return Ok(settings_page(&app, shell, None, Some("A job may run for a day at most (1440 minutes).".into())).await);
340 +
    }
341 +
    let e = |e| Error::from_db(shell.clone(), e);
342 +
    accounts::set_setting(&app.db, crate::flow::SETTING_DEFAULT, &d.to_string()).await.map_err(e)?;
343 +
    accounts::set_setting(&app.db, crate::flow::SETTING_MAX, &m.to_string()).await.map_err(e)?;
344 +
    accounts::set_setting(&app.db, crate::flow::SETTING_GRACE, &g.to_string()).await.map_err(e)?;
345 +
    accounts::audit(
346 +
        &app.db,
347 +
        Some(&me),
348 +
        "flow.settings",
349 +
        "",
350 +
        &format!("job time limit {d} min by default, {m} min at most; lost after {g} min more"),
351 +
    )
352 +
    .await
353 +
    .ok();
354 +
    Ok(settings_page(&app, shell, Some("Saved. Jobs claimed from now on run under these limits.".into()), None).await)
355 +
}
modified crates/hub/tests/accounts.rs +29 −0
…
540 unchanged lines
541 541
    // The last admin can be neither disabled nor deleted.
542 542
    assert!(act("andy", "delete", "andy").await.contains("Not on yourself"));
543 543
}
544 +
545 +
/// oxygenhub#89: an admin sets the site's job time limits on Admin →
546 +
/// Runners → Settings; nobody else can.
547 +
#[tokio::test(flavor = "multi_thread")]
548 +
async fn admins_set_the_job_time_limits() {
549 +
    let h = hub().await;
550 +
    let app = &h.app;
551 +
    let andy = register(app, "andy", "").await;
552 +
    let page = get(app, "/admin/runners/settings", Some(&andy)).await;
553 +
    assert_eq!(page.status, StatusCode::OK);
554 +
    assert!(page.body.contains("Job time limits") && page.body.contains("value=\"60\""), "the hub.toml default shows");
555 +
    let csrf = csrf_of(&page.body);
556 +
    let r = post(app, "/admin/runners/settings", Some(&andy), &format!("_csrf={csrf}&default_minutes=200&max_minutes=120&grace_minutes=5"))
557 +
        .await;
558 +
    assert!(r.body.contains("cannot be longer than the maximum"), "{}", r.body);
559 +
    let r = post(app, "/admin/runners/settings", Some(&andy), &format!("_csrf={csrf}&default_minutes=90&max_minutes=240&grace_minutes=10"))
560 +
        .await;
561 +
    assert!(r.body.contains("Saved."), "{}", r.body);
562 +
    let l = oxygenhub::flow::limits(app).await;
563 +
    assert_eq!((l.default_s, l.max_s, l.grace_ms), (5400, 14400, 600_000));
564 +
    assert!(get(app, "/admin/runners", Some(&andy)).await.body.contains("href=\"/admin/runners/settings\""));
565 +
    let code = {
566 +
        let csrf = csrf_of(&get(app, "/admin", Some(&andy)).await.body);
567 +
        let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await;
568 +
        r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string()
569 +
    };
570 +
    let bob = register(app, "bob", &code).await;
571 +
    assert_ne!(get(app, "/admin/runners/settings", Some(&bob)).await.status, StatusCode::OK, "not for others");
572 +
}
modified crates/hub/tests/flow.rs +42 −0
…
1483 unchanged lines
1484 1484
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1485 1485
    assert_eq!(names(h.app.clone(), 3).await, ["build", "dist", "lint", "test"], "a version bump runs dist");
1486 1486
}
1487 +
1488 +
/// oxygenflow#23 and oxygenhub#89: a job runs under its pipeline's
1489 +
/// timeout, capped at the site's maximum, else the site's default; and the
1490 +
/// hub gives a job up only past its own limit.
1491 +
#[tokio::test(flavor = "multi_thread")]
1492 +
async fn a_job_runs_under_its_own_time_limit_within_the_sites() {
1493 +
    let h = hub().await;
1494 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
1495 +
    db::accounts::set_setting(&h.app.db, oxygenhub::flow::SETTING_DEFAULT, "30").await.unwrap();
1496 +
    db::accounts::set_setting(&h.app.db, oxygenhub::flow::SETTING_MAX, "120").await.unwrap();
1497 +
    create(&h.app, "andy", "limits").await;
1498 +
    let url = format!("{}/andy/limits", h.base);
1499 +
    let work = tempfile::tempdir().unwrap();
1500 +
    let c = client(&url, work.path());
1501 +
    let extra = "job \"slow\" {\n    sandbox \"alpine\"\n    timeout \"90m\"\n    exec \"true\"\n}\njob \"huge\" {\n    sandbox \"alpine\"\n    timeout \"5h\"\n    exec \"true\"\n}\n";
1502 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(extra));
1503 +
    write(work.path(), "test.sh", "echo ok\n");
1504 +
    c.commit("Add a pipeline", &[]).unwrap();
1505 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1506 +
    assert_eq!(runs_of(&h.app, "andy", "limits").await.len(), 1);
1507 +
    let mut limits = std::collections::HashMap::new();
1508 +
    let mut ids = std::collections::HashMap::new();
1509 +
    while let Some(j) = claim(&h.app).await {
1510 +
        let name = j["job"]["name"].as_str().unwrap().to_string();
1511 +
        limits.insert(name.clone(), j["timeout_s"].as_u64().unwrap());
1512 +
        ids.insert(name, j["job_id"].as_i64().unwrap());
1513 +
    }
1514 +
    assert_eq!(limits["slow"], 5400, "its own");
1515 +
    assert_eq!(limits["huge"], 7200, "cut to the site's maximum");
1516 +
    assert_eq!(limits["build"], 1800, "the site's default");
1517 +
    // 40 minutes in: past the default and its grace, within slow's limit.
1518 +
    let started = db::now_ms() - 40 * 60 * 1000;
1519 +
    sqlx::query("UPDATE flow_jobs SET started_ms = ?").bind(started).execute(&h.app.db).await.unwrap();
1520 +
    oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap();
1521 +
    let status = |id: i64| {
1522 +
        let app = h.app.clone();
1523 +
        async move { db::flow::job(&app.db, id).await.unwrap().unwrap().status }
1524 +
    };
1525 +
    assert_eq!(status(ids["build"]).await, "error", "past its 30 minutes");
1526 +
    assert_eq!(status(ids["slow"]).await, "running", "within its 90");
1527 +
    assert_eq!(status(ids["huge"]).await, "running", "within its 120");
1528 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified templates/admin_runners.html +1 −0
…
2 unchanged lines
3 3
{% block mainclass %} class="wide"{% endblock %}
4 4
{% block content %}
5 5
<div class="head"><h1>Runners <small>{{ runners.len()|plural("runner", "runners") }} · <a href="/admin">Administration</a></small></h1></div>
6 +
<div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/settings">settings</a></span></div>
6 7
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
7 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
8 9
{% if let Some(t) = new_token %}
…
53 unchanged lines