OXYGENHUB

Coverage / crates/hub/src/insights.rs 94.5%

242 of 256 lines run, 56 of 113 functions · run #523 on main · run missed

1
//! Insights (oxygenhub#257): OxygenFlow's standard numbers over time, for
2
//! the site, an owner or a repository: how long jobs wait for a runner, how
3
//! many run and how they end, how long they take, how deep the queue is and
4
//! how busy the runners are. All from `flow_jobs` (migration 0080's queue
5
//! and ready times) and the runners' rows, so nothing new is collected;
6
//! power users point Grafana at `/metrics` for anything more.
7
8
use crate::db::{self, Pool};
9
use sqlx::FromRow;
10
11
/// The span a page shows.
12
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13
pub enum Range {
14
    Hour,
15
    Day,
16
    Week,
17
}
18
19
impl Range {
204×
    pub fn parse(s: &str) -> Range {
214×
        match s {
224×
            "1h" => Range::Hour,
233×
            "7d" => Range::Week,
242×
            _ => Range::Day,
25
        }
264×
    }
27
2832×
    pub fn key(self) -> &'static str {
2932×
        match self {
307×
            Range::Hour => "1h",
3117×
            Range::Day => "24h",
328×
            Range::Week => "7d",
33
        }
3432×
    }
35
369×
    pub fn ms(self) -> i64 {
379×
        match self {
382×
            Range::Hour => 3_600_000,
394×
            Range::Day => 86_400_000,
403×
            Range::Week => 7 * 86_400_000,
41
        }
429×
    }
43
44
    /// How many buckets the charts have.
458×
    pub fn buckets(self) -> usize {
468×
        match self {
472×
            Range::Hour => 60,
484×
            Range::Day => 48,
492×
            Range::Week => 56,
50
        }
518×
    }
52
534×
    pub fn bucket_ms(self) -> i64 {
544×
        self.ms() / self.buckets() as i64
554×
    }
56
}
57
58
/// Which jobs count.
59
#[derive(Clone, Debug, Default)]
60
pub struct Scope {
61
    /// An owner's repositories.
62
    pub owner: Option<String>,
63
    /// One repository.
64
    pub repo_id: Option<i64>,
65
    /// One runner's jobs.
66
    pub runner: Option<String>,
67
}
68
69
#[derive(Clone, Debug, FromRow)]
70
struct JobRow {
71
    status: String,
72
    runner: String,
73
    owner: String,
74
    repo: String,
75
    queued_ms: Option<i64>,
76
    ready_ms: Option<i64>,
77
    started_ms: Option<i64>,
78
    finished_ms: Option<i64>,
79
}
80
81
impl JobRow {
82
    /// Seconds from ready to started, when it started.
8336×
    fn pickup(&self) -> Option<f64> {
8436×
        Some((self.started_ms? - self.ready_ms.or(self.queued_ms)?).max(0) as f64 / 1000.0)
8536×
    }
86
8724×
    fn duration(&self) -> Option<f64> {
8824×
        Some((self.finished_ms? - self.started_ms?).max(0) as f64 / 1000.0)
8924×
    }
90
91
    /// When it left the queue: started, or ended without starting.
9212×
    fn left_queue(&self) -> Option<i64> {
9312×
        self.started_ms.or(self.finished_ms)
9412×
    }
95
9648×
    fn ended(&self) -> bool {
9748×
        !matches!(self.status.as_str(), "queued" | "running")
9848×
    }
99
}
100
101
/// One chart's line or stack: a value per bucket.
102
#[derive(Clone, Debug)]
103
pub struct Series {
104
    pub name: &'static str,
105
    /// The CSS class that colours it (`s-ok`, `s-bad`, …).
106
    pub class: &'static str,
107
    pub values: Vec<Option<f64>>,
108
}
109
110
/// One runner's row.
111
#[derive(Clone, Debug)]
112
pub struct RunnerRow {
113
    pub name: String,
114
    pub status: &'static str,
115
    pub running: i64,
116
    pub max_jobs: i64,
117
    pub jobs: i64,
118
    pub failed: i64,
119
    pub pickup_median: Option<f64>,
120
    pub duration_median: Option<f64>,
121
    pub last_seen_ms: Option<i64>,
122
}
123
124
/// One repository's row.
125
#[derive(Clone, Debug)]
126
pub struct RepoRow {
127
    pub slug: String,
128
    pub jobs: i64,
129
    pub failed: i64,
130
    pub pickup_median: Option<f64>,
131
    pub pickup_p95: Option<f64>,
132
    pub queued: i64,
133
}
134
135
/// Everything a page draws.
136
#[derive(Clone, Debug)]
137
pub struct Insights {
138
    pub range: Range,
139
    /// The first bucket's start; buckets follow every `range.bucket_ms()`.
140
    pub start_ms: i64,
141
    pub now_ms: i64,
142
    /// Seconds: median and 95th percentile per bucket, by the job's start.
143
    pub pickup: Vec<Series>,
144
    /// Jobs that ended per bucket, by result.
145
    pub results: Vec<Series>,
146
    /// Seconds: median and 95th percentile per bucket, by the job's end.
147
    pub durations: Vec<Series>,
148
    /// Jobs in the queue and running, at each bucket's end.
149
    pub load: Vec<Series>,
150
    /// Running jobs over the online runners' slots, percent, per bucket.
151
    pub utilisation: Vec<Series>,
152
    pub ended: i64,
153
    pub passed: i64,
154
    pub failed: i64,
155
    pub pickup_median: Option<f64>,
156
    pub pickup_p95: Option<f64>,
157
    pub duration_median: Option<f64>,
158
    pub queued_now: i64,
159
    pub running_now: i64,
160
    pub runners_online: i64,
161
    pub runners_total: i64,
162
    pub slots_busy: i64,
163
    pub slots_total: i64,
164
    pub runners: Vec<RunnerRow>,
165
    pub repos: Vec<RepoRow>,
166
}
167
168
/// The `p`-th percentile (0..=1) of `v`, nearest rank.
169879×
pub fn percentile(v: &mut [f64], p: f64) -> Option<f64> {
170879×
    if v.is_empty() {
171833×
        return None;
17246×
    }
173128×
    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
17446×
    let rank = ((p * v.len() as f64).ceil() as usize).clamp(1, v.len());
17546×
    Some(v[rank - 1])
176879×
}
177
1784×
async fn rows(pool: &Pool, scope: &Scope, since: i64) -> sqlx::Result<Vec<JobRow>> {
1794×
    sqlx::query_as(
1804×
        "SELECT j.status, j.runner, p.owner, p.name AS repo, COALESCE(j.queued_ms, r.created_ms) AS queued_ms, j.ready_ms,
1814×
                j.started_ms, j.finished_ms
1824×
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
1834×
         WHERE (j.finished_ms >= $1 OR j.status IN ('queued', 'running'))
1844×
           AND ($2 = '' OR p.owner = $3) AND ($4 IS NULL OR p.id = $5) AND ($6 = '' OR j.runner = $7)",
1854×
    )
1864×
    .bind(since)
1874×
    .bind(scope.owner.as_deref().unwrap_or(""))
1884×
    .bind(scope.owner.as_deref().unwrap_or(""))
1894×
    .bind(scope.repo_id)
1904×
    .bind(scope.repo_id)
1914×
    .bind(scope.runner.as_deref().unwrap_or(""))
1924×
    .bind(scope.runner.as_deref().unwrap_or(""))
1934×
    .fetch_all(pool)
1944×
    .await
1954×
}
196
197
/// Gather a scope's numbers over `range`, ending now.
1984×
pub async fn gather(pool: &Pool, scope: &Scope, range: Range) -> sqlx::Result<Insights> {
1994×
    let now = db::now_ms();
2004×
    let start = now - range.ms();
2014×
    let jobs = rows(pool, scope, start).await?;
2024×
    let runners = db::runners::list(pool).await?;
2034×
    let n = range.buckets();
2044×
    let width = range.bucket_ms();
20524×
    let bucket = |t: i64| -> Option<usize> { (t >= start && t <= now).then(|| (((t - start) / width) as usize).min(n - 1)) };
206
2074×
    let mut pickups: Vec<Vec<f64>> = vec![Vec::new(); n];
2084×
    let mut durations: Vec<Vec<f64>> = vec![Vec::new(); n];
2094×
    let (mut passed, mut failed, mut other) = (vec![0f64; n], vec![0f64; n], vec![0f64; n]);
2104×
    let (mut all_pickups, mut all_durations) = (Vec::new(), Vec::new());
21112×
    for j in &jobs {
21212×
        if let (Some(p), Some(b)) = (j.pickup(), j.started_ms.and_then(bucket)) {
21312×
            pickups[b].push(p);
21412×
            all_pickups.push(p);
21512×
        }
21612×
        if let (Some(f), true) = (j.finished_ms, j.ended()) {
21712×
            if let Some(b) = bucket(f) {
21812×
                match j.status.as_str() {
21912×
                    "passed" => passed[b] += 1.0,
2200×
                    "failed" => failed[b] += 1.0,
2210×
                    _ => other[b] += 1.0,
222
                }
22312×
                if let Some(d) = j.duration() {
22412×
                    durations[b].push(d);
22512×
                    all_durations.push(d);
22612×
                }
2270×
            }
2280×
        }
229
    }
230
    // The queue and the running jobs at each bucket's end.
2314×
    let (mut queued_at, mut running_at) = (vec![0f64; n], vec![0f64; n]);
232212×
    for (i, (q, r)) in queued_at.iter_mut().zip(running_at.iter_mut()).enumerate() {
233212×
        let t = (start + (i as i64 + 1) * width).min(now);
234636×
        for j in &jobs {
235636×
            let entered = j.queued_ms.unwrap_or(i64::MAX);
236636×
            if entered <= t && j.left_queue().is_none_or(|l| l > t) && !(j.ended() && j.finished_ms.is_none()) {
2370×
                *q += 1.0;
238636×
            }
239636×
            if j.started_ms.is_some_and(|s| s <= t) && j.finished_ms.is_none_or(|f| f > t) && !(j.ended() && j.finished_ms.is_none()) {
2400×
                *r += 1.0;
241636×
            }
242
        }
243
    }
244
    // Slots: the runners serving the scope that are online now.
2454×
    let serving: Vec<&db::runners::Runner> =
2464×
        runners.iter().filter(|r| scope.runner.as_deref().is_none_or(|n| n == r.name)).filter(|r| r.state != "removed").collect();
2474×
    let slots_total: i64 = serving.iter().filter(|r| r.online()).map(|r| r.max_jobs.max(1)).sum();
2484×
    let utilisation: Vec<Option<f64>> =
249212×
        running_at.iter().map(|r| (slots_total > 0).then(|| (r / slots_total as f64 * 100.0).min(100.0))).collect();
250
251848×
    let quantiles = |sets: &mut Vec<Vec<f64>>, p: f64| -> Vec<Option<f64>> { sets.iter_mut().map(|v| percentile(v, p)).collect() };
2524×
    let pickup = vec![
2534×
        Series { name: "median", class: "s-main", values: quantiles(&mut pickups, 0.5) },
2544×
        Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut pickups, 0.95) },
255
    ];
2564×
    let durations_series = vec![
2574×
        Series { name: "median", class: "s-main", values: quantiles(&mut durations, 0.5) },
2584×
        Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut durations, 0.95) },
259
    ];
26020×
    let some = |v: Vec<f64>| v.into_iter().map(Some).collect::<Vec<_>>();
26112×
    let ended_total = |v: &[f64]| v.iter().sum::<f64>() as i64;
2624×
    let (p_total, f_total, o_total) = (ended_total(&passed), ended_total(&failed), ended_total(&other));
263
264
    // Runners: what each did in the range.
2654×
    let mut runner_rows: Vec<RunnerRow> = serving
2664×
        .iter()
2674×
        .filter(|r| scope.owner.is_none() && scope.repo_id.is_none() || jobs.iter().any(|j| j.runner == r.name))
2684×
        .map(|r| {
26912×
            let mine: Vec<&JobRow> = jobs.iter().filter(|j| j.runner == r.name).collect();
27012×
            let mut p: Vec<f64> = mine.iter().filter(|j| j.started_ms.is_some_and(|s| s >= start)).filter_map(|j| j.pickup()).collect();
27112×
            let mut d: Vec<f64> = mine.iter().filter(|j| j.ended()).filter_map(|j| j.duration()).collect();
272
            RunnerRow {
2734×
                name: r.name.clone(),
2744×
                status: r.status(),
27512×
                running: mine.iter().filter(|j| j.status == "running").count() as i64,
2764×
                max_jobs: r.max_jobs.max(1),
27712×
                jobs: mine.iter().filter(|j| j.ended()).count() as i64,
27812×
                failed: mine.iter().filter(|j| j.status == "failed").count() as i64,
2794×
                pickup_median: percentile(&mut p, 0.5),
2804×
                duration_median: percentile(&mut d, 0.5),
2814×
                last_seen_ms: r.last_seen_ms,
282
            }
2834×
        })
2844×
        .collect();
2854×
    runner_rows.sort_by(|a, b| b.jobs.cmp(&a.jobs).then_with(|| a.name.cmp(&b.name)));
286
287
    // Repositories, busiest first (a repository's own page has just itself).
2884×
    let mut by_repo: std::collections::BTreeMap<String, (i64, i64, Vec<f64>, i64)> = Default::default();
28912×
    for j in &jobs {
29012×
        let e = by_repo.entry(format!("{}/{}", j.owner, j.repo)).or_default();
29112×
        if j.ended() && j.finished_ms.is_some_and(|f| f >= start) {
29212×
            e.0 += 1;
29312×
            if j.status == "failed" {
2940×
                e.1 += 1;
29512×
            }
2960×
        }
29712×
        if let Some(p) = j.pickup().filter(|_| j.started_ms.is_some_and(|s| s >= start)) {
29812×
            e.2.push(p);
29912×
        }
30012×
        if j.status == "queued" {
3010×
            e.3 += 1;
30212×
        }
303
    }
3044×
    let mut repos: Vec<RepoRow> = by_repo
3054×
        .into_iter()
3064×
        .map(|(slug, (jobs, failed, mut p, queued))| RepoRow {
3074×
            slug,
3084×
            jobs,
3094×
            failed,
3104×
            pickup_median: percentile(&mut p, 0.5),
3114×
            pickup_p95: percentile(&mut p, 0.95),
3124×
            queued,
3134×
        })
3144×
        .collect();
3154×
    repos.sort_by(|a, b| (b.jobs + b.queued).cmp(&(a.jobs + a.queued)).then_with(|| a.slug.cmp(&b.slug)));
3164×
    repos.truncate(15);
317
318
    Ok(Insights {
3194×
        range,
3204×
        start_ms: start,
3214×
        now_ms: now,
3224×
        pickup,
3234×
        results: vec![
3244×
            Series { name: "passed", class: "s-ok", values: some(passed) },
3254×
            Series { name: "failed", class: "s-bad", values: some(failed) },
3264×
            Series { name: "other (error, cancelled, skipped)", class: "s-mute", values: some(other) },
327
        ],
3284×
        durations: durations_series,
3294×
        load: vec![
3304×
            Series { name: "queued", class: "s-warn", values: some(queued_at) },
3314×
            Series { name: "running", class: "s-main", values: some(running_at) },
332
        ],
3334×
        utilisation: vec![Series { name: "busy slots", class: "s-main", values: utilisation }],
3344×
        ended: p_total + f_total + o_total,
3354×
        passed: p_total,
3364×
        failed: f_total,
3374×
        pickup_median: percentile(&mut all_pickups, 0.5),
3384×
        pickup_p95: percentile(&mut all_pickups, 0.95),
3394×
        duration_median: percentile(&mut all_durations, 0.5),
34012×
        queued_now: jobs.iter().filter(|j| j.status == "queued").count() as i64,
34112×
        running_now: jobs.iter().filter(|j| j.status == "running").count() as i64,
3424×
        runners_online: serving.iter().filter(|r| r.online()).count() as i64,
3434×
        runners_total: serving.len() as i64,
34412×
        slots_busy: jobs.iter().filter(|j| j.status == "running").count() as i64,
3454×
        slots_total,
3464×
        runners: runner_rows,
3474×
        repos,
348
    })
3494×
}
350
351
/// A span of seconds, short: `42 s`, `3.5 min`, `1.2 h`.
35247×
pub fn seconds(s: f64) -> String {
35347×
    if s < 60.0 {
35445×
        format!("{s:.0} s")
3552×
    } else if s < 3600.0 {
3561×
        let m = s / 60.0;
3571×
        if m < 10.0 {
3581×
            format!("{m:.1} min")
359
        } else {
3600×
            format!("{m:.0} min")
361
        }
362
    } else {
3631×
        format!("{:.1} h", s / 3600.0)
364
    }
36547×
}
366
367
#[cfg(test)]
368
mod tests {
369
    use super::*;
370
371
    #[test]
3721×
    fn percentiles_are_nearest_rank() {
3731×
        let mut v = vec![5.0, 1.0, 3.0, 2.0, 4.0];
3741×
        assert_eq!(percentile(&mut v, 0.5), Some(3.0));
3751×
        assert_eq!(percentile(&mut v, 0.95), Some(5.0));
3761×
        assert_eq!(percentile(&mut [], 0.5), None);
3771×
        assert_eq!(seconds(42.0), "42 s");
3781×
        assert_eq!(seconds(210.0), "3.5 min");
3791×
        assert_eq!(seconds(4320.0), "1.2 h");
3801×
    }
381
}