| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | | use crate::db::{self, Pool}; |
| 9 | | use sqlx::FromRow; |
| 10 | |
|
| 11 | |
|
| 12 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 13 | | pub enum Range { |
| 14 | | Hour, |
| 15 | | Day, |
| 16 | | Week, |
| 17 | | } |
| 18 | |
|
| 19 | | impl Range { |
| 20 | 4× | pub fn parse(s: &str) -> Range { |
| 21 | 4× | match s { |
| 22 | 4× | "1h" => Range::Hour, |
| 23 | 3× | "7d" => Range::Week, |
| 24 | 2× | _ => Range::Day, |
| 25 | | } |
| 26 | 4× | } |
| 27 | |
|
| 28 | 32× | pub fn key(self) -> &'static str { |
| 29 | 32× | match self { |
| 30 | 7× | Range::Hour => "1h", |
| 31 | 17× | Range::Day => "24h", |
| 32 | 8× | Range::Week => "7d", |
| 33 | | } |
| 34 | 32× | } |
| 35 | |
|
| 36 | 9× | pub fn ms(self) -> i64 { |
| 37 | 9× | match self { |
| 38 | 2× | Range::Hour => 3_600_000, |
| 39 | 4× | Range::Day => 86_400_000, |
| 40 | 3× | Range::Week => 7 * 86_400_000, |
| 41 | | } |
| 42 | 9× | } |
| 43 | |
|
| 44 | | |
| 45 | 8× | pub fn buckets(self) -> usize { |
| 46 | 8× | match self { |
| 47 | 2× | Range::Hour => 60, |
| 48 | 4× | Range::Day => 48, |
| 49 | 2× | Range::Week => 56, |
| 50 | | } |
| 51 | 8× | } |
| 52 | |
|
| 53 | 4× | pub fn bucket_ms(self) -> i64 { |
| 54 | 4× | self.ms() / self.buckets() as i64 |
| 55 | 4× | } |
| 56 | | } |
| 57 | |
|
| 58 | |
|
| 59 | | #[derive(Clone, Debug, Default)] |
| 60 | | pub struct Scope { |
| 61 | | |
| 62 | | pub owner: Option<String>, |
| 63 | | |
| 64 | | pub repo_id: Option<i64>, |
| 65 | | |
| 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 | | |
| 83 | 36× | fn pickup(&self) -> Option<f64> { |
| 84 | 36× | Some((self.started_ms? - self.ready_ms.or(self.queued_ms)?).max(0) as f64 / 1000.0) |
| 85 | 36× | } |
| 86 | |
|
| 87 | 24× | fn duration(&self) -> Option<f64> { |
| 88 | 24× | Some((self.finished_ms? - self.started_ms?).max(0) as f64 / 1000.0) |
| 89 | 24× | } |
| 90 | |
|
| 91 | | |
| 92 | 12× | fn left_queue(&self) -> Option<i64> { |
| 93 | 12× | self.started_ms.or(self.finished_ms) |
| 94 | 12× | } |
| 95 | |
|
| 96 | 48× | fn ended(&self) -> bool { |
| 97 | 48× | !matches!(self.status.as_str(), "queued" | "running") |
| 98 | 48× | } |
| 99 | | } |
| 100 | |
|
| 101 | |
|
| 102 | | #[derive(Clone, Debug)] |
| 103 | | pub struct Series { |
| 104 | | pub name: &'static str, |
| 105 | | |
| 106 | | pub class: &'static str, |
| 107 | | pub values: Vec<Option<f64>>, |
| 108 | | } |
| 109 | |
|
| 110 | |
|
| 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 | |
|
| 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 | |
|
| 136 | | #[derive(Clone, Debug)] |
| 137 | | pub struct Insights { |
| 138 | | pub range: Range, |
| 139 | | |
| 140 | | pub start_ms: i64, |
| 141 | | pub now_ms: i64, |
| 142 | | |
| 143 | | pub pickup: Vec<Series>, |
| 144 | | |
| 145 | | pub results: Vec<Series>, |
| 146 | | |
| 147 | | pub durations: Vec<Series>, |
| 148 | | |
| 149 | | pub load: Vec<Series>, |
| 150 | | |
| 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 | |
|
| 169 | 879× | pub fn percentile(v: &mut [f64], p: f64) -> Option<f64> { |
| 170 | 879× | if v.is_empty() { |
| 171 | 833× | return None; |
| 172 | 46× | } |
| 173 | 116× | v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); |
| 174 | 46× | let rank = ((p * v.len() as f64).ceil() as usize).clamp(1, v.len()); |
| 175 | 46× | Some(v[rank - 1]) |
| 176 | 879× | } |
| 177 | |
|
| 178 | 4× | async fn rows(pool: &Pool, scope: &Scope, since: i64) -> sqlx::Result<Vec<JobRow>> { |
| 179 | 4× | sqlx::query_as( |
| 180 | 4× | "SELECT j.status, j.runner, p.owner, p.name AS repo, COALESCE(j.queued_ms, r.created_ms) AS queued_ms, j.ready_ms, |
| 181 | 4× | j.started_ms, j.finished_ms |
| 182 | 4× | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id |
| 183 | 4× | WHERE (j.finished_ms >= $1 OR j.status IN ('queued', 'running')) |
| 184 | 4× | AND ($2 = '' OR p.owner = $3) AND ($4 IS NULL OR p.id = $5) AND ($6 = '' OR j.runner = $7)", |
| 185 | 4× | ) |
| 186 | 4× | .bind(since) |
| 187 | 4× | .bind(scope.owner.as_deref().unwrap_or("")) |
| 188 | 4× | .bind(scope.owner.as_deref().unwrap_or("")) |
| 189 | 4× | .bind(scope.repo_id) |
| 190 | 4× | .bind(scope.repo_id) |
| 191 | 4× | .bind(scope.runner.as_deref().unwrap_or("")) |
| 192 | 4× | .bind(scope.runner.as_deref().unwrap_or("")) |
| 193 | 4× | .fetch_all(pool) |
| 194 | 4× | .await |
| 195 | 4× | } |
| 196 | |
|
| 197 | |
|
| 198 | 4× | pub async fn gather(pool: &Pool, scope: &Scope, range: Range) -> sqlx::Result<Insights> { |
| 199 | 4× | let now = db::now_ms(); |
| 200 | 4× | let start = now - range.ms(); |
| 201 | 4× | let jobs = rows(pool, scope, start).await?; |
| 202 | 4× | let runners = db::runners::list(pool).await?; |
| 203 | 4× | let n = range.buckets(); |
| 204 | 4× | let width = range.bucket_ms(); |
| 205 | 24× | let bucket = |t: i64| -> Option<usize> { (t >= start && t <= now).then(|| (((t - start) / width) as usize).min(n - 1)) }; |
| 206 | |
|
| 207 | 4× | let mut pickups: Vec<Vec<f64>> = vec![Vec::new(); n]; |
| 208 | 4× | let mut durations: Vec<Vec<f64>> = vec![Vec::new(); n]; |
| 209 | 4× | let (mut passed, mut failed, mut other) = (vec![0f64; n], vec![0f64; n], vec![0f64; n]); |
| 210 | 4× | let (mut all_pickups, mut all_durations) = (Vec::new(), Vec::new()); |
| 211 | 12× | for j in &jobs { |
| 212 | 12× | if let (Some(p), Some(b)) = (j.pickup(), j.started_ms.and_then(bucket)) { |
| 213 | 12× | pickups[b].push(p); |
| 214 | 12× | all_pickups.push(p); |
| 215 | 12× | } |
| 216 | 12× | if let (Some(f), true) = (j.finished_ms, j.ended()) { |
| 217 | 12× | if let Some(b) = bucket(f) { |
| 218 | 12× | match j.status.as_str() { |
| 219 | 12× | "passed" => passed[b] += 1.0, |
| 220 | 0× | "failed" => failed[b] += 1.0, |
| 221 | 0× | _ => other[b] += 1.0, |
| 222 | | } |
| 223 | 12× | if let Some(d) = j.duration() { |
| 224 | 12× | durations[b].push(d); |
| 225 | 12× | all_durations.push(d); |
| 226 | 12× | } |
| 227 | 0× | } |
| 228 | 0× | } |
| 229 | | } |
| 230 | | |
| 231 | 4× | let (mut queued_at, mut running_at) = (vec![0f64; n], vec![0f64; n]); |
| 232 | 212× | for (i, (q, r)) in queued_at.iter_mut().zip(running_at.iter_mut()).enumerate() { |
| 233 | 212× | let t = (start + (i as i64 + 1) * width).min(now); |
| 234 | 636× | for j in &jobs { |
| 235 | 636× | let entered = j.queued_ms.unwrap_or(i64::MAX); |
| 236 | 636× | if entered <= t && j.left_queue().is_none_or(|l| l > t) && !(j.ended() && j.finished_ms.is_none()) { |
| 237 | 0× | *q += 1.0; |
| 238 | 636× | } |
| 239 | 636× | 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()) { |
| 240 | 0× | *r += 1.0; |
| 241 | 636× | } |
| 242 | | } |
| 243 | | } |
| 244 | | |
| 245 | 4× | let serving: Vec<&db::runners::Runner> = |
| 246 | 4× | runners.iter().filter(|r| scope.runner.as_deref().is_none_or(|n| n == r.name)).filter(|r| r.state != "removed").collect(); |
| 247 | 4× | let slots_total: i64 = serving.iter().filter(|r| r.online()).map(|r| r.max_jobs.max(1)).sum(); |
| 248 | 4× | let utilisation: Vec<Option<f64>> = |
| 249 | 212× | running_at.iter().map(|r| (slots_total > 0).then(|| (r / slots_total as f64 * 100.0).min(100.0))).collect(); |
| 250 | |
|
| 251 | 848× | let quantiles = |sets: &mut Vec<Vec<f64>>, p: f64| -> Vec<Option<f64>> { sets.iter_mut().map(|v| percentile(v, p)).collect() }; |
| 252 | 4× | let pickup = vec![ |
| 253 | 4× | Series { name: "median", class: "s-main", values: quantiles(&mut pickups, 0.5) }, |
| 254 | 4× | Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut pickups, 0.95) }, |
| 255 | | ]; |
| 256 | 4× | let durations_series = vec![ |
| 257 | 4× | Series { name: "median", class: "s-main", values: quantiles(&mut durations, 0.5) }, |
| 258 | 4× | Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut durations, 0.95) }, |
| 259 | | ]; |
| 260 | 20× | let some = |v: Vec<f64>| v.into_iter().map(Some).collect::<Vec<_>>(); |
| 261 | 12× | let ended_total = |v: &[f64]| v.iter().sum::<f64>() as i64; |
| 262 | 4× | let (p_total, f_total, o_total) = (ended_total(&passed), ended_total(&failed), ended_total(&other)); |
| 263 | |
|
| 264 | | |
| 265 | 4× | let mut runner_rows: Vec<RunnerRow> = serving |
| 266 | 4× | .iter() |
| 267 | 4× | .filter(|r| scope.owner.is_none() && scope.repo_id.is_none() || jobs.iter().any(|j| j.runner == r.name)) |
| 268 | 4× | .map(|r| { |
| 269 | 12× | let mine: Vec<&JobRow> = jobs.iter().filter(|j| j.runner == r.name).collect(); |
| 270 | 12× | let mut p: Vec<f64> = mine.iter().filter(|j| j.started_ms.is_some_and(|s| s >= start)).filter_map(|j| j.pickup()).collect(); |
| 271 | 12× | let mut d: Vec<f64> = mine.iter().filter(|j| j.ended()).filter_map(|j| j.duration()).collect(); |
| 272 | | RunnerRow { |
| 273 | 4× | name: r.name.clone(), |
| 274 | 4× | status: r.status(), |
| 275 | 12× | running: mine.iter().filter(|j| j.status == "running").count() as i64, |
| 276 | 4× | max_jobs: r.max_jobs.max(1), |
| 277 | 12× | jobs: mine.iter().filter(|j| j.ended()).count() as i64, |
| 278 | 12× | failed: mine.iter().filter(|j| j.status == "failed").count() as i64, |
| 279 | 4× | pickup_median: percentile(&mut p, 0.5), |
| 280 | 4× | duration_median: percentile(&mut d, 0.5), |
| 281 | 4× | last_seen_ms: r.last_seen_ms, |
| 282 | | } |
| 283 | 4× | }) |
| 284 | 4× | .collect(); |
| 285 | 4× | runner_rows.sort_by(|a, b| b.jobs.cmp(&a.jobs).then_with(|| a.name.cmp(&b.name))); |
| 286 | |
|
| 287 | | |
| 288 | 4× | let mut by_repo: std::collections::BTreeMap<String, (i64, i64, Vec<f64>, i64)> = Default::default(); |
| 289 | 12× | for j in &jobs { |
| 290 | 12× | let e = by_repo.entry(format!("{}/{}", j.owner, j.repo)).or_default(); |
| 291 | 12× | if j.ended() && j.finished_ms.is_some_and(|f| f >= start) { |
| 292 | 12× | e.0 += 1; |
| 293 | 12× | if j.status == "failed" { |
| 294 | 0× | e.1 += 1; |
| 295 | 12× | } |
| 296 | 0× | } |
| 297 | 12× | if let Some(p) = j.pickup().filter(|_| j.started_ms.is_some_and(|s| s >= start)) { |
| 298 | 12× | e.2.push(p); |
| 299 | 12× | } |
| 300 | 12× | if j.status == "queued" { |
| 301 | 0× | e.3 += 1; |
| 302 | 12× | } |
| 303 | | } |
| 304 | 4× | let mut repos: Vec<RepoRow> = by_repo |
| 305 | 4× | .into_iter() |
| 306 | 4× | .map(|(slug, (jobs, failed, mut p, queued))| RepoRow { |
| 307 | 4× | slug, |
| 308 | 4× | jobs, |
| 309 | 4× | failed, |
| 310 | 4× | pickup_median: percentile(&mut p, 0.5), |
| 311 | 4× | pickup_p95: percentile(&mut p, 0.95), |
| 312 | 4× | queued, |
| 313 | 4× | }) |
| 314 | 4× | .collect(); |
| 315 | 4× | repos.sort_by(|a, b| (b.jobs + b.queued).cmp(&(a.jobs + a.queued)).then_with(|| a.slug.cmp(&b.slug))); |
| 316 | 4× | repos.truncate(15); |
| 317 | |
|
| 318 | | Ok(Insights { |
| 319 | 4× | range, |
| 320 | 4× | start_ms: start, |
| 321 | 4× | now_ms: now, |
| 322 | 4× | pickup, |
| 323 | 4× | results: vec![ |
| 324 | 4× | Series { name: "passed", class: "s-ok", values: some(passed) }, |
| 325 | 4× | Series { name: "failed", class: "s-bad", values: some(failed) }, |
| 326 | 4× | Series { name: "other (error, cancelled, skipped)", class: "s-mute", values: some(other) }, |
| 327 | | ], |
| 328 | 4× | durations: durations_series, |
| 329 | 4× | load: vec![ |
| 330 | 4× | Series { name: "queued", class: "s-warn", values: some(queued_at) }, |
| 331 | 4× | Series { name: "running", class: "s-main", values: some(running_at) }, |
| 332 | | ], |
| 333 | 4× | utilisation: vec![Series { name: "busy slots", class: "s-main", values: utilisation }], |
| 334 | 4× | ended: p_total + f_total + o_total, |
| 335 | 4× | passed: p_total, |
| 336 | 4× | failed: f_total, |
| 337 | 4× | pickup_median: percentile(&mut all_pickups, 0.5), |
| 338 | 4× | pickup_p95: percentile(&mut all_pickups, 0.95), |
| 339 | 4× | duration_median: percentile(&mut all_durations, 0.5), |
| 340 | 12× | queued_now: jobs.iter().filter(|j| j.status == "queued").count() as i64, |
| 341 | 12× | running_now: jobs.iter().filter(|j| j.status == "running").count() as i64, |
| 342 | 4× | runners_online: serving.iter().filter(|r| r.online()).count() as i64, |
| 343 | 4× | runners_total: serving.len() as i64, |
| 344 | 12× | slots_busy: jobs.iter().filter(|j| j.status == "running").count() as i64, |
| 345 | 4× | slots_total, |
| 346 | 4× | runners: runner_rows, |
| 347 | 4× | repos, |
| 348 | | }) |
| 349 | 4× | } |
| 350 | |
|
| 351 | |
|
| 352 | 47× | pub fn seconds(s: f64) -> String { |
| 353 | 47× | if s < 60.0 { |
| 354 | 45× | format!("{s:.0} s") |
| 355 | 2× | } else if s < 3600.0 { |
| 356 | 1× | let m = s / 60.0; |
| 357 | 1× | if m < 10.0 { |
| 358 | 1× | format!("{m:.1} min") |
| 359 | | } else { |
| 360 | 0× | format!("{m:.0} min") |
| 361 | | } |
| 362 | | } else { |
| 363 | 1× | format!("{:.1} h", s / 3600.0) |
| 364 | | } |
| 365 | 47× | } |
| 366 | |
|
| 367 | | #[cfg(test)] |
| 368 | | mod tests { |
| 369 | | use super::*; |
| 370 | |
|
| 371 | | #[test] |
| 372 | 1× | fn percentiles_are_nearest_rank() { |
| 373 | 1× | let mut v = vec![5.0, 1.0, 3.0, 2.0, 4.0]; |
| 374 | 1× | assert_eq!(percentile(&mut v, 0.5), Some(3.0)); |
| 375 | 1× | assert_eq!(percentile(&mut v, 0.95), Some(5.0)); |
| 376 | 1× | assert_eq!(percentile(&mut [], 0.5), None); |
| 377 | 1× | assert_eq!(seconds(42.0), "42 s"); |
| 378 | 1× | assert_eq!(seconds(210.0), "3.5 min"); |
| 379 | 1× | assert_eq!(seconds(4320.0), "1.2 h"); |
| 380 | 1× | } |
| 381 | | } |