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 |