OXYGENHUB

Flow's job times in /metrics (refs oxygenflow#35, closes #212) 9e8cbfc3ad00

oxyhub_flow_job_queue_seconds (ready to started) and
oxyhub_flow_job_run_seconds histograms by runner, finished jobs by runner
and result, reused passes, and the work queue's waiting and dead items,
over the last day. The runners' own /metrics is oxygenflow's (b56fedae).
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5bf5f43a2650
When
· 2026-10-21 01:52 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
9e8cbfc3ad009a781e33aaae48d3e97f90d15c59b5ec9d7c360a0905f0aee2cc
Depends on
8a48dd830178 a6d824c5ebf5 a7e6e8e6094c b5668108487f b9b74d997cec dd327d76062d e57765dee61a
Depended on by
0468394734e9 adbc38e7ea09
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
6 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Flow's job times in /metrics (oxygenflow#35, #212):** `oxyhub_flow_job_queue_seconds` (ready to started) and `oxyhub_flow_job_run_seconds` histograms by runner, finished jobs by runner and result, reused passes, and the work queue's waiting and dead items, all over the last day, for the Grafana dashboard.
7 8
- **A durable work queue (#276):** work that fails where it starts is queued and retried instead of lost: a notification's email the mail server refused, and a Git mirror export after a push. Any worker process takes due items (on PostgreSQL several at once, each skipping what another holds), retries back off from 30 seconds to an hour, and an item still failing after eight attempts is kept as dead.
8 9
- **One auto-land and one Diffusion bump at a time across hub processes (#275):** auto-land's check of a proposal, and Diffusion's bump and landing of a consumer's pin, were serialised only within a process, so two web processes could land one proposal twice. Each now holds a lock named for the proposal or the pin across every process sharing the database (a PostgreSQL advisory lock; on SQLite, one process, as before).
9 10
- **Several web processes can share a data directory (#274):** the transparency log took appends from one process only, so only one process per data directory could have the web role. Each append now holds a lock in the log's directory and starts from the log as the files hold it, readers see every process's entries, and every process serves the newest checkpoint, whichever signed it.
…
277 unchanged lines
modified crates/hub/src/metrics.rs +102 −0
…
339 unchanged lines
340 340
            .map(|(o, st, _, _)| (format!("owner=\"{}\"", label(o)), if st == "ok" { 1.0 } else { 0.0 }))
341 341
            .collect::<Vec<_>>(),
342 342
    );
343 +
    flow_timings(app, &mut out, now).await;
344 +
    if let Ok((waiting, dead)) = db::queue::counts(&app.db).await {
345 +
        family(
346 +
            &mut out,
347 +
            "oxyhub_queue_items",
348 +
            "Items in the durable work queue (#276): waiting (due, backing off or claimed) and dead (failed every attempt).",
349 +
            "gauge",
350 +
            &[("state=\"waiting\"".into(), waiting as f64), ("state=\"dead\"".into(), dead as f64)],
351 +
        );
352 +
    }
343 353
    http_families(&mut out);
344 354
    out
345 355
}
356 +
357 +
/// A finished job's runner, result, note, and when it was ready, started
358 +
/// and finished.
359 +
type JobTimes = (String, String, String, Option<i64>, Option<i64>, Option<i64>);
346 360
361 +
/// Bucket bounds for job times, in seconds.
362 +
const JOB_SECONDS: [f64; 11] = [1.0, 5.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1200.0, 1800.0, 3600.0];
363 +
364 +
/// Flow's job times over the last day (oxygenflow#35), by runner: how long
365 +
/// jobs waited from ready to started (queue time) and ran (run time), and
366 +
/// how many finished, by result, and were reused (#265).
367 +
async fn flow_timings(app: &Shared, out: &mut String, now: i64) {
368 +
    let rows: Vec<JobTimes> = sqlx::query_as(
369 +
        "SELECT runner, status, error, COALESCE(ready_ms, queued_ms), started_ms, finished_ms FROM flow_jobs \
370 +
         WHERE finished_ms >= $1 AND status IN ('passed', 'failed', 'error', 'cancelled')",
371 +
    )
372 +
    .bind(now - 86_400_000)
373 +
    .fetch_all(&app.db)
374 +
    .await
375 +
    .unwrap_or_default();
376 +
    let mut queue: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
377 +
    let mut run: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
378 +
    let mut finished: std::collections::BTreeMap<(String, String), f64> = Default::default();
379 +
    let mut reused = 0.0;
380 +
    for (runner, status, error, ready, started, finished_ms) in &rows {
381 +
        if error.starts_with("reused from run #") {
382 +
            reused += 1.0;
383 +
            continue;
384 +
        }
385 +
        let r = if runner.is_empty() { "none".to_string() } else { runner.clone() };
386 +
        *finished.entry((r.clone(), status.clone())).or_default() += 1.0;
387 +
        if let (Some(ready), Some(started)) = (ready, started) {
388 +
            queue.entry(r.clone()).or_default().push((started - ready).max(0) as f64 / 1000.0);
389 +
        }
390 +
        if let (Some(started), Some(f)) = (started, finished_ms) {
391 +
            run.entry(r).or_default().push((f - started).max(0) as f64 / 1000.0);
392 +
        }
393 +
    }
394 +
    histogram(
395 +
        out,
396 +
        "oxyhub_flow_job_queue_seconds",
397 +
        "How long finished jobs of the last day waited from ready to started, by runner.",
398 +
        &queue,
399 +
    );
400 +
    histogram(out, "oxyhub_flow_job_run_seconds", "How long finished jobs of the last day ran, by runner.", &run);
401 +
    family(
402 +
        out,
403 +
        "oxyhub_flow_jobs_finished_last_day",
404 +
        "Jobs that finished in the last day, by runner and result (none: never placed).",
405 +
        "gauge",
406 +
        &finished.iter().map(|((r, st), n)| (format!("runner=\"{}\",status=\"{}\"", label(r), label(st)), *n)).collect::<Vec<_>>(),
407 +
    );
408 +
    family(
409 +
        out,
410 +
        "oxyhub_flow_jobs_reused_last_day",
411 +
        "Jobs of the last day that reused a pass on the same state instead of running (#265).",
412 +
        "gauge",
413 +
        &[(String::new(), reused)],
414 +
    );
415 +
}
416 +
417 +
/// A histogram family from samples (seconds) per runner.
418 +
fn histogram(out: &mut String, name: &str, help: &str, by_runner: &std::collections::BTreeMap<String, Vec<f64>>) {
419 +
    let _ = writeln!(out, "# HELP {name} {help}");
420 +
    let _ = writeln!(out, "# TYPE {name} histogram");
421 +
    for (runner, samples) in by_runner {
422 +
        let l = format!("runner=\"{}\"", label(runner));
423 +
        for b in JOB_SECONDS {
424 +
            let n = samples.iter().filter(|s| **s <= b).count();
425 +
            let _ = writeln!(out, "{name}_bucket{{{l},le=\"{b}\"}} {n}");
426 +
        }
427 +
        let _ = writeln!(out, "{name}_bucket{{{l},le=\"+Inf\"}} {}", samples.len());
428 +
        let _ = writeln!(out, "{name}_sum{{{l}}} {}", samples.iter().sum::<f64>());
429 +
        let _ = writeln!(out, "{name}_count{{{l}}} {}", samples.len());
430 +
    }
431 +
}
432 +
347 433
/// `GET /metrics`.
348 434
pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response {
349 435
    let want = &app.config.server.metrics_token;
…
10 unchanged lines
360 446
361 447
#[cfg(test)]
362 448
mod tests {
449 +
450 +
    #[test]
451 +
    fn job_times_are_cumulative_buckets_per_runner() {
452 +
        let mut by: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
453 +
        by.insert("earth-1".into(), vec![0.5, 20.0, 4000.0]);
454 +
        let mut out = String::new();
455 +
        super::histogram(&mut out, "x_seconds", "help", &by);
456 +
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"1\"} 1"), "{out}");
457 +
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"30\"} 2"), "{out}");
458 +
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"3600\"} 2"), "{out}");
459 +
        assert!(
460 +
            out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"+Inf\"} 3") && out.contains("x_seconds_count{runner=\"earth-1\"} 3"),
461 +
            "{out}"
462 +
        );
463 +
    }
464 +
363 465
    #[test]
364 466
    fn route_classes_hold_no_names() {
365 467
        for (path, class) in [
…
14 unchanged lines
modified crates/hub/tests/roles.rs +9 −0
…
109 unchanged lines
110 110
    }
111 111
    let (status, body) = get(&app, "/metrics", Some("m")).await;
112 112
    assert_eq!(status, StatusCode::OK, "{body}");
113 +
    // Flow's job times (oxygenflow#35) and the work queue (#276).
114 +
    for family in [
115 +
        "# TYPE oxyhub_flow_job_queue_seconds histogram",
116 +
        "# TYPE oxyhub_flow_job_run_seconds histogram",
117 +
        "oxyhub_flow_jobs_reused_last_day 0",
118 +
        "oxyhub_queue_items{state=\"dead\"} 0",
119 +
    ] {
120 +
        assert!(body.contains(family), "{family}: {body}");
121 +
    }
113 122
    // It reads the transparency log but never writes it.
114 123
    assert!(app.tlog.append(&[]).is_err());
115 124
}
…
126 unchanged lines