1use std::collections::{BTreeMap, BTreeSet};
7
8use crate::api::{
9 self, AutoNote, Digest, Insight, InsightKind, Insights, JobState, LongWait, RepositoryWaits,
10 RunLink, Severity, WaitSpread, WeekWaits,
11};
12use crate::config::{ByteSize, Config};
13use crate::sizing::spread;
14
15const SECOND: u64 = 1000;
16const DAY: u64 = 24 * 3600 * SECOND;
17const WEEK: u64 = 7 * DAY;
18
19const RECENT: usize = 5;
21const SLOWER_PERCENT: u64 = 20;
23const SLOWER_SECONDS: u64 = 30;
25
26const CAP_RUNS: usize = 10;
28const CAP_MIN_RUNS: usize = 5;
29const NEAR_CAP: u64 = 85;
31const NEAR_CAP_WARNING: usize = 2;
33const FLAKY_WARNING: u32 = 2;
35
36const WAITS_PERCENT: u64 = 50;
39const WAITS_SECONDS: u64 = 60;
40const WAITS_MIN_JOBS: u32 = 5;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Run {
45 pub id: u64,
47 pub repository: String,
49 pub workflow: Option<String>,
51 pub name: String,
53 pub branch: Option<String>,
55 pub url: Option<String>,
57 pub run_id: u64,
59 pub run_attempt: u32,
61 pub state: JobState,
63 pub conclusion: Option<String>,
65 pub queued_at: u64,
67 pub started_at: Option<u64>,
69 pub finished_at: Option<u64>,
71 pub runner_started_at: Option<u64>,
73 pub profile: Option<String>,
75 pub memory_peak: Option<u64>,
77}
78
79impl Run {
80 fn finished(&self) -> u64 {
81 self.finished_at.unwrap_or(0)
82 }
83
84 fn succeeded(&self) -> bool {
85 self.state == JobState::Completed && self.conclusion.as_deref() == Some("success")
86 }
87
88 fn failed(&self) -> bool {
90 self.state == JobState::Lost || self.failed_itself()
91 }
92
93 fn failed_itself(&self) -> bool {
95 self.state == JobState::Completed
96 && matches!(self.conclusion.as_deref(), Some("failure" | "timed_out"))
97 }
98
99 fn took(&self) -> Option<u64> {
101 Some(self.finished_at?.saturating_sub(self.started_at?) / SECOND)
102 }
103
104 fn waited(&self) -> Option<u64> {
105 Some(self.started_at?.saturating_sub(self.queued_at) / SECOND)
106 }
107
108 fn link(&self) -> RunLink {
109 RunLink {
110 job: self.id,
111 run_id: self.run_id,
112 run_attempt: self.run_attempt,
113 branch: self.branch.clone(),
114 url: self.url.clone(),
115 at: self.finished() / SECOND,
116 }
117 }
118}
119
120type JobKey<'run> = (&'run str, Option<&'run str>, &'run str);
122
123fn key_of(run: &Run) -> JobKey<'_> {
124 (&run.repository, run.workflow.as_deref(), &run.name)
125}
126
127pub fn insights(runs: &[Run], config: &Config, days: u32, now: u64) -> Insights {
130 let since = now.saturating_sub(u64::from(days) * DAY);
131 let window: Vec<&Run> = runs
132 .iter()
133 .filter(|run| {
134 matches!(run.state, JobState::Completed | JobState::Lost)
135 && run
136 .finished_at
137 .is_some_and(|at| (since..=now).contains(&at))
138 })
139 .collect();
140 let mut jobs: BTreeMap<JobKey<'_>, Vec<&Run>> = BTreeMap::new();
141 for run in &window {
142 jobs.entry(key_of(run)).or_default().push(run);
143 }
144 let mut found = Vec::new();
145 for (key, runs) in &mut jobs {
146 runs.sort_by_key(|run| (run.finished(), run.id));
147 found.extend(slower(*key, runs));
148 found.extend(flaky(*key, runs));
149 found.extend(near_cap(*key, runs, config));
150 }
151 let waits = waits(runs, now);
152 found.extend(waits.iter().filter_map(waits_insight));
153 found.sort_by(|a, b| {
154 (a.severity, &a.repository, &a.workflow, &a.job, &a.key).cmp(&(
155 b.severity,
156 &b.repository,
157 &b.workflow,
158 &b.job,
159 &b.key,
160 ))
161 });
162 Insights {
163 since: since / SECOND,
164 until: now / SECOND,
165 insights: found,
166 waits,
167 }
168}
169
170pub fn digest(runs: &[Run], config: &Config, now: u64) -> Digest {
173 let since = now.saturating_sub(DAY);
174 let finished: Vec<&Run> = runs
175 .iter()
176 .filter(|run| {
177 matches!(run.state, JobState::Completed | JobState::Lost)
178 && run.finished_at.is_some_and(|at| at > since && at <= now)
179 })
180 .collect();
181 let before: BTreeSet<String> = insights(runs, config, api::INSIGHT_DAYS, since)
182 .insights
183 .into_iter()
184 .map(|insight| insight.key)
185 .collect();
186 let new = insights(runs, config, api::INSIGHT_DAYS, now)
187 .insights
188 .into_iter()
189 .filter(|insight| !before.contains(&insight.key))
190 .collect();
191 let mut started: Vec<&Run> = runs
192 .iter()
193 .filter(|run| run.started_at.is_some_and(|at| at > since && at <= now))
194 .collect();
195 started.sort_by_key(|run| (std::cmp::Reverse(run.waited()), run.id));
196 Digest {
197 since: since / SECOND,
198 until: now / SECOND,
199 finished: finished.len() as u64,
200 failed: finished.iter().filter(|run| run.failed()).count() as u64,
201 new,
202 waits: started
203 .into_iter()
204 .take(3)
205 .map(|run| LongWait {
206 job: run.id,
207 repository: run.repository.clone(),
208 workflow: run.workflow.clone(),
209 name: run.name.clone(),
210 waited: run.waited().unwrap_or(0),
211 url: run.url.clone(),
212 })
213 .collect(),
214 }
215}
216
217fn subject((repository, workflow, name): JobKey<'_>) -> String {
219 match workflow {
220 Some(workflow) => format!("{repository}: {workflow} / {name}"),
221 None => format!("{repository}: {name}"),
222 }
223}
224
225fn key_text(kind: &str, (repository, workflow, name): JobKey<'_>) -> String {
226 format!("{kind}:{repository}/{}/{name}", workflow.unwrap_or(""))
227}
228
229fn insight(
230 key: JobKey<'_>,
231 kind: &str,
232 severity: Severity,
233 sentence: String,
234 found: InsightKind,
235 runs: Vec<RunLink>,
236) -> Insight {
237 Insight {
238 key: key_text(kind, key),
239 severity,
240 repository: key.0.to_owned(),
241 workflow: key.1.map(str::to_owned),
242 job: Some(key.2.to_owned()),
243 sentence,
244 kind: found,
245 runs,
246 }
247}
248
249fn median(values: &[u64]) -> u64 {
251 let mut values = values.to_vec();
252 values.sort_unstable();
253 let middle = values.len() / 2;
254 match values.len() {
255 0 => 0,
256 len if len % 2 == 1 => values[middle],
257 _ => u64::midpoint(values[middle - 1], values[middle]),
258 }
259}
260
261fn slower(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
263 let timed_on = |branch: &str| -> Vec<(&Run, u64)> {
264 runs.iter()
265 .filter(|run| run.succeeded() && run.branch.as_deref() == Some(branch))
266 .filter_map(|run| Some((*run, run.took()?)))
267 .collect()
268 };
269 let mut branches: BTreeMap<&str, usize> = BTreeMap::new();
270 for run in runs.iter().filter(|run| run.succeeded()) {
271 if let Some(branch) = &run.branch {
272 *branches.entry(branch).or_default() += 1;
273 }
274 }
275 let busiest = branches
278 .iter()
279 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
280 .map(|(branch, _)| *branch)?;
281 let (branch, timed) = ["main", "master"]
282 .into_iter()
283 .map(|branch| (branch, timed_on(branch)))
284 .find(|(_, timed)| timed.len() >= 2 * RECENT)
285 .unwrap_or_else(|| (busiest, timed_on(busiest)));
286 if timed.len() < 2 * RECENT {
287 return None;
288 }
289 let (baseline, recent) = timed.split_at(timed.len() - RECENT);
290 let took = |runs: &[(&Run, u64)]| runs.iter().map(|(_, took)| *took).collect::<Vec<_>>();
291 let before = median(&took(baseline));
292 let now = median(&took(recent));
293 let threshold = (before + before * SLOWER_PERCENT / 100).max(before + SLOWER_SECONDS);
294 if now < threshold {
295 return None;
296 }
297 let mut since = None;
299 let mut fast_in_a_row = 0;
300 for (run, took) in &timed {
301 if *took >= threshold {
302 fast_in_a_row = 0;
303 since = since.or(Some(*run));
304 } else {
305 fast_in_a_row += 1;
306 if fast_in_a_row >= 2 {
307 since = None;
308 }
309 }
310 }
311 let since = since?;
312 let sentence = format!(
313 "{} on {branch} takes {} now, up from {}.",
314 subject(key),
315 span(now),
316 span(before)
317 );
318 let links = recent.iter().rev().map(|(run, _)| run.link()).collect();
319 Some(insight(
320 key,
321 "slower",
322 Severity::Warning,
323 sentence,
324 InsightKind::Slower {
325 branch: Some(branch.to_owned()),
326 before,
327 now,
328 since: Some(since.link()),
329 },
330 links,
331 ))
332}
333
334fn flaky(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
336 let mut by_run: BTreeMap<u64, Vec<&Run>> = BTreeMap::new();
337 for run in runs {
338 by_run.entry(run.run_id).or_default().push(run);
339 }
340 let mut failed_first: Vec<&Run> = by_run
341 .values()
342 .filter_map(|attempts| {
343 attempts.iter().copied().find(|failed| {
344 failed.failed_itself()
345 && attempts
346 .iter()
347 .any(|later| later.run_attempt > failed.run_attempt && later.succeeded())
348 })
349 })
350 .collect();
351 if failed_first.is_empty() {
352 return None;
353 }
354 failed_first.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
355 let flaky = u32::try_from(failed_first.len()).unwrap_or(u32::MAX);
356 let total = u32::try_from(by_run.len()).unwrap_or(u32::MAX);
357 let sentence = format!(
358 "{} failed and then passed on a re-run in {flaky} of its {total} runs.",
359 subject(key)
360 );
361 let severity = if flaky >= FLAKY_WARNING {
363 Severity::Warning
364 } else {
365 Severity::Notice
366 };
367 Some(insight(
368 key,
369 "flaky",
370 severity,
371 sentence,
372 InsightKind::Flaky { flaky, total },
373 failed_first.iter().take(5).map(|run| run.link()).collect(),
374 ))
375}
376
377fn near_cap(key: JobKey<'_>, runs: &[&Run], config: &Config) -> Option<Insight> {
379 let measured: Vec<&Run> = runs
380 .iter()
381 .rev()
382 .filter(|run| run.memory_peak.is_some() && run.profile.is_some())
383 .take(CAP_RUNS)
384 .copied()
385 .collect();
386 if measured.len() < CAP_MIN_RUNS {
387 return None;
388 }
389 let name = measured.first()?.profile.as_deref()?;
390 let profile = config.profiles.get(name)?;
391 let cap = profile.memory.0;
392 let peaks: Vec<u64> = measured.iter().filter_map(|run| run.memory_peak).collect();
393 let near = |peak: &u64| peak.saturating_mul(100) >= cap.saturating_mul(NEAR_CAP);
394 let peak = spread(peaks.clone()).p95;
395 if cap == 0 || !near(&peak) {
396 return None;
397 }
398 let near_runs = peaks.iter().filter(|peak| near(peak)).count();
399 let auto = if !config.sizing.auto {
400 AutoNote::Off
401 } else if profile
402 .max_memory
403 .is_some_and(|max| profile.memory.0 >= max.0)
404 {
405 AutoNote::AtMax
406 } else {
407 AutoNote::Raises
408 };
409 let what = match auto {
410 AutoNote::Raises => format!("Auto sizing raises {name} when a run meets the cap."),
411 AutoNote::AtMax => {
412 format!("{name} is at its max_memory, so auto sizing cannot raise it.")
413 }
414 AutoNote::Off => format!("Auto sizing is off: raise {name}'s memory, or turn it on."),
415 };
416 let sentence = format!(
417 "{} peaks at {}, {}% of {name}'s {}. {what}",
418 subject(key),
419 ByteSize(peak),
420 peak.saturating_mul(100) / cap,
421 ByteSize(cap)
422 );
423 let severity = if auto == AutoNote::Raises || near_runs < NEAR_CAP_WARNING {
425 Severity::Notice
426 } else {
427 Severity::Warning
428 };
429 Some(insight(
430 key,
431 "near-cap",
432 severity,
433 sentence,
434 InsightKind::NearCap {
435 profile: name.to_owned(),
436 peak,
437 cap,
438 auto,
439 },
440 measured.iter().take(5).map(|run| run.link()).collect(),
441 ))
442}
443
444fn waits(runs: &[Run], now: u64) -> Vec<RepositoryWaits> {
446 let mut weeks: BTreeMap<&str, [Vec<&Run>; 2]> = BTreeMap::new();
447 for run in runs {
448 let Some(started) = run.started_at.filter(|at| *at <= now) else {
449 continue;
450 };
451 let week = match now - started {
452 back if back < WEEK => 0,
453 back if back < 2 * WEEK => 1,
454 _ => continue,
455 };
456 weeks.entry(&run.repository).or_default()[week].push(run);
457 }
458 weeks
459 .into_iter()
460 .map(|(repository, [now, before])| RepositoryWaits {
461 repository: repository.to_owned(),
462 now: week_waits(&now),
463 before: week_waits(&before),
464 })
465 .collect()
466}
467
468fn week_waits(runs: &[&Run]) -> WeekWaits {
469 let warm = |run: &&&Run| run.runner_started_at.is_some_and(|at| at <= run.queued_at);
470 let of = |runs: Vec<&&Run>| {
471 let waited: Vec<u64> = runs.iter().filter_map(|run| run.waited()).collect();
472 let jobs = u32::try_from(waited.len()).unwrap_or(u32::MAX);
473 let spread = (!waited.is_empty()).then(|| spread(waited));
474 WaitSpread {
475 jobs,
476 p50: spread.as_ref().map(|spread| spread.p50),
477 p95: spread.as_ref().map(|spread| spread.p95),
478 }
479 };
480 WeekWaits {
481 all: of(runs.iter().collect()),
482 warm: of(runs.iter().filter(warm).collect()),
483 cold: of(runs.iter().filter(|run| !warm(run)).collect()),
484 }
485}
486
487fn waits_insight(waits: &RepositoryWaits) -> Option<Insight> {
488 if waits.now.all.jobs < WAITS_MIN_JOBS || waits.before.all.jobs < WAITS_MIN_JOBS {
489 return None;
490 }
491 let (before, now) = (waits.before.all.p95?, waits.now.all.p95?);
492 let threshold = (before + before * WAITS_PERCENT / 100).max(before + WAITS_SECONDS);
493 if now < threshold {
494 return None;
495 }
496 Some(Insight {
497 key: format!("waits:{}", waits.repository),
498 severity: Severity::Notice,
499 repository: waits.repository.clone(),
500 workflow: None,
501 job: None,
502 sentence: format!(
503 "{}: 1 job in 20 waits {} or more for a runner this week, up from {} the week before.",
504 waits.repository,
505 span(now),
506 span(before)
507 ),
508 kind: InsightKind::Waits { before, now },
509 runs: Vec::new(),
510 })
511}
512
513fn span(seconds: u64) -> String {
515 match seconds {
516 0..60 => format!("{seconds}s"),
517 60..3600 if seconds.is_multiple_of(60) => format!("{}m", seconds / 60),
518 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
519 _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
520 }
521}
522
523#[cfg(test)]
524mod tests;