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 pub build_cache: Option<api::BuildCacheCounts>,
79}
80
81impl Run {
82 fn finished(&self) -> u64 {
83 self.finished_at.unwrap_or(0)
84 }
85
86 fn succeeded(&self) -> bool {
87 self.state == JobState::Completed && self.conclusion.as_deref() == Some("success")
88 }
89
90 fn failed(&self) -> bool {
92 self.state == JobState::Lost || self.failed_itself()
93 }
94
95 fn failed_itself(&self) -> bool {
97 self.state == JobState::Completed
98 && matches!(self.conclusion.as_deref(), Some("failure" | "timed_out"))
99 }
100
101 fn took(&self) -> Option<u64> {
103 Some(self.finished_at?.saturating_sub(self.started_at?) / SECOND)
104 }
105
106 fn waited(&self) -> Option<u64> {
107 Some(self.started_at?.saturating_sub(self.queued_at) / SECOND)
108 }
109
110 fn link(&self) -> RunLink {
111 RunLink {
112 job: self.id,
113 run_id: self.run_id,
114 run_attempt: self.run_attempt,
115 branch: self.branch.clone(),
116 url: self.url.clone(),
117 at: self.finished() / SECOND,
118 }
119 }
120}
121
122type JobKey<'run> = (&'run str, Option<&'run str>, &'run str);
124
125fn key_of(run: &Run) -> JobKey<'_> {
126 (&run.repository, run.workflow.as_deref(), &run.name)
127}
128
129pub fn insights(runs: &[Run], config: &Config, days: u32, now: u64) -> Insights {
132 let since = now.saturating_sub(u64::from(days) * DAY);
133 let window: Vec<&Run> = runs
134 .iter()
135 .filter(|run| {
136 matches!(run.state, JobState::Completed | JobState::Lost)
137 && run
138 .finished_at
139 .is_some_and(|at| (since..=now).contains(&at))
140 })
141 .collect();
142 let mut jobs: BTreeMap<JobKey<'_>, Vec<&Run>> = BTreeMap::new();
143 for run in &window {
144 jobs.entry(key_of(run)).or_default().push(run);
145 }
146 let mut found = Vec::new();
147 for (key, runs) in &mut jobs {
148 runs.sort_by_key(|run| (run.finished(), run.id));
149 found.extend(slower(*key, runs));
150 found.extend(flaky(*key, runs));
151 found.extend(near_cap(*key, runs, config));
152 found.extend(cold_cache(*key, runs));
153 }
154 let waits = waits(runs, now);
155 found.extend(waits.iter().filter_map(waits_insight));
156 found.sort_by(|a, b| {
157 (a.severity, &a.repository, &a.workflow, &a.job, &a.key).cmp(&(
158 b.severity,
159 &b.repository,
160 &b.workflow,
161 &b.job,
162 &b.key,
163 ))
164 });
165 Insights {
166 since: since / SECOND,
167 until: now / SECOND,
168 insights: found,
169 waits,
170 }
171}
172
173pub fn digest(runs: &[Run], config: &Config, now: u64) -> Digest {
176 let since = now.saturating_sub(DAY);
177 let finished: Vec<&Run> = runs
178 .iter()
179 .filter(|run| {
180 matches!(run.state, JobState::Completed | JobState::Lost)
181 && run.finished_at.is_some_and(|at| at > since && at <= now)
182 })
183 .collect();
184 let before: BTreeSet<String> = insights(runs, config, api::INSIGHT_DAYS, since)
185 .insights
186 .into_iter()
187 .map(|insight| insight.key)
188 .collect();
189 let new = insights(runs, config, api::INSIGHT_DAYS, now)
190 .insights
191 .into_iter()
192 .filter(|insight| !before.contains(&insight.key))
193 .collect();
194 let mut started: Vec<&Run> = runs
195 .iter()
196 .filter(|run| run.started_at.is_some_and(|at| at > since && at <= now))
197 .collect();
198 started.sort_by_key(|run| (std::cmp::Reverse(run.waited()), run.id));
199 Digest {
200 since: since / SECOND,
201 until: now / SECOND,
202 finished: finished.len() as u64,
203 failed: finished.iter().filter(|run| run.failed()).count() as u64,
204 new,
205 waits: started
206 .into_iter()
207 .take(3)
208 .map(|run| LongWait {
209 job: run.id,
210 repository: run.repository.clone(),
211 workflow: run.workflow.clone(),
212 name: run.name.clone(),
213 waited: run.waited().unwrap_or(0),
214 url: run.url.clone(),
215 })
216 .collect(),
217 }
218}
219
220fn subject((repository, workflow, name): JobKey<'_>) -> String {
222 match workflow {
223 Some(workflow) => format!("{repository}: {workflow} / {name}"),
224 None => format!("{repository}: {name}"),
225 }
226}
227
228fn key_text(kind: &str, (repository, workflow, name): JobKey<'_>) -> String {
229 format!("{kind}:{repository}/{}/{name}", workflow.unwrap_or(""))
230}
231
232fn insight(
233 key: JobKey<'_>,
234 kind: &str,
235 severity: Severity,
236 sentence: String,
237 found: InsightKind,
238 runs: Vec<RunLink>,
239) -> Insight {
240 Insight {
241 key: key_text(kind, key),
242 severity,
243 repository: key.0.to_owned(),
244 workflow: key.1.map(str::to_owned),
245 job: Some(key.2.to_owned()),
246 sentence,
247 kind: found,
248 runs,
249 }
250}
251
252fn median(values: &[u64]) -> u64 {
254 let mut values = values.to_vec();
255 values.sort_unstable();
256 let middle = values.len() / 2;
257 match values.len() {
258 0 => 0,
259 len if len % 2 == 1 => values[middle],
260 _ => u64::midpoint(values[middle - 1], values[middle]),
261 }
262}
263
264fn slower(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
266 let timed_on = |branch: &str| -> Vec<(&Run, u64)> {
267 runs.iter()
268 .filter(|run| run.succeeded() && run.branch.as_deref() == Some(branch))
269 .filter_map(|run| Some((*run, run.took()?)))
270 .collect()
271 };
272 let mut branches: BTreeMap<&str, usize> = BTreeMap::new();
273 for run in runs.iter().filter(|run| run.succeeded()) {
274 if let Some(branch) = &run.branch {
275 *branches.entry(branch).or_default() += 1;
276 }
277 }
278 let busiest = branches
281 .iter()
282 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
283 .map(|(branch, _)| *branch)?;
284 let (branch, timed) = ["main", "master"]
285 .into_iter()
286 .map(|branch| (branch, timed_on(branch)))
287 .find(|(_, timed)| timed.len() >= 2 * RECENT)
288 .unwrap_or_else(|| (busiest, timed_on(busiest)));
289 if timed.len() < 2 * RECENT {
290 return None;
291 }
292 let (baseline, recent) = timed.split_at(timed.len() - RECENT);
293 let took = |runs: &[(&Run, u64)]| runs.iter().map(|(_, took)| *took).collect::<Vec<_>>();
294 let before = median(&took(baseline));
295 let now = median(&took(recent));
296 let threshold = (before + before * SLOWER_PERCENT / 100).max(before + SLOWER_SECONDS);
297 if now < threshold || recent.iter().any(|(_, took)| *took <= before) {
300 return None;
301 }
302 let mut since = None;
304 let mut fast_in_a_row = 0;
305 for (run, took) in &timed {
306 if *took >= threshold {
307 fast_in_a_row = 0;
308 since = since.or(Some(*run));
309 } else {
310 fast_in_a_row += 1;
311 if fast_in_a_row >= 2 {
312 since = None;
313 }
314 }
315 }
316 let since = since?;
317 let sentence = format!(
318 "{} on {branch} takes {} now, up from {}.",
319 subject(key),
320 span(now),
321 span(before)
322 );
323 let links = recent.iter().rev().map(|(run, _)| run.link()).collect();
324 Some(insight(
325 key,
326 "slower",
327 Severity::Warning,
328 sentence,
329 InsightKind::Slower {
330 branch: Some(branch.to_owned()),
331 before,
332 now,
333 since: Some(since.link()),
334 },
335 links,
336 ))
337}
338
339fn flaky(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
341 let mut by_run: BTreeMap<u64, Vec<&Run>> = BTreeMap::new();
342 for run in runs {
343 by_run.entry(run.run_id).or_default().push(run);
344 }
345 let mut failed_first: Vec<&Run> = by_run
346 .values()
347 .filter_map(|attempts| {
348 attempts.iter().copied().find(|failed| {
349 failed.failed_itself()
350 && attempts
351 .iter()
352 .any(|later| later.run_attempt > failed.run_attempt && later.succeeded())
353 })
354 })
355 .collect();
356 if failed_first.is_empty() {
357 return None;
358 }
359 failed_first.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
360 let flaky = u32::try_from(failed_first.len()).unwrap_or(u32::MAX);
361 let total = u32::try_from(by_run.len()).unwrap_or(u32::MAX);
362 let sentence = format!(
363 "{} failed and then passed on a re-run in {flaky} of its {total} runs.",
364 subject(key)
365 );
366 let severity = if flaky >= FLAKY_WARNING {
368 Severity::Warning
369 } else {
370 Severity::Notice
371 };
372 Some(insight(
373 key,
374 "flaky",
375 severity,
376 sentence,
377 InsightKind::Flaky { flaky, total },
378 failed_first.iter().take(5).map(|run| run.link()).collect(),
379 ))
380}
381
382fn near_cap(key: JobKey<'_>, runs: &[&Run], config: &Config) -> Option<Insight> {
384 let measured: Vec<&Run> = runs
385 .iter()
386 .rev()
387 .filter(|run| run.memory_peak.is_some() && run.profile.is_some())
388 .take(CAP_RUNS)
389 .copied()
390 .collect();
391 if measured.len() < CAP_MIN_RUNS {
392 return None;
393 }
394 let name = measured.first()?.profile.as_deref()?;
395 let profile = config.profiles.get(name)?;
396 let cap = profile.memory.0;
397 let peaks: Vec<u64> = measured.iter().filter_map(|run| run.memory_peak).collect();
398 let near = |peak: &u64| peak.saturating_mul(100) >= cap.saturating_mul(NEAR_CAP);
399 let peak = spread(peaks.clone()).p95;
400 if cap == 0 || !near(&peak) {
401 return None;
402 }
403 let near_runs = peaks.iter().filter(|peak| near(peak)).count();
404 let auto = if !config.sizing.auto {
405 AutoNote::Off
406 } else if profile
407 .max_memory
408 .is_some_and(|max| profile.memory.0 >= max.0)
409 {
410 AutoNote::AtMax
411 } else {
412 AutoNote::Raises
413 };
414 let what = match auto {
415 AutoNote::Raises => format!("Auto sizing raises {name} when a run meets the cap."),
416 AutoNote::AtMax => {
417 format!("{name} is at its max_memory, so auto sizing cannot raise it.")
418 }
419 AutoNote::Off => format!("Auto sizing is off: raise {name}'s memory, or turn it on."),
420 };
421 let sentence = format!(
422 "{} peaks at {}, {}% of {name}'s {}. {what}",
423 subject(key),
424 ByteSize(peak),
425 peak.saturating_mul(100) / cap,
426 ByteSize(cap)
427 );
428 let severity = if auto == AutoNote::Raises || near_runs < NEAR_CAP_WARNING {
430 Severity::Notice
431 } else {
432 Severity::Warning
433 };
434 Some(insight(
435 key,
436 "near-cap",
437 severity,
438 sentence,
439 InsightKind::NearCap {
440 profile: name.to_owned(),
441 peak,
442 cap,
443 auto,
444 },
445 measured.iter().take(5).map(|run| run.link()).collect(),
446 ))
447}
448
449const COLD_RUNS: usize = 5;
451const COLD_MIN_RUNS: usize = 3;
452const COLD_MIN_LOOKUPS: u64 = 10;
453const COLD_PERCENT: u64 = 10;
456
457fn cold_cache(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
458 let writers: Vec<&Run> = runs
459 .iter()
460 .rev()
461 .filter(|run| {
462 run.build_cache
463 .is_some_and(|cache| cache.writer && cache.lookups() >= COLD_MIN_LOOKUPS)
464 })
465 .take(COLD_RUNS)
466 .copied()
467 .collect();
468 if writers.len() < COLD_MIN_RUNS {
469 return None;
470 }
471 let mut total = api::BuildCacheCounts::default();
472 for run in &writers {
473 total.add(&run.build_cache.unwrap_or_default());
474 }
475 let percent = total.hit_percent()?;
476 if percent > COLD_PERCENT {
477 return None;
478 }
479 let branch = writers.first()?.branch.clone();
480 let sentence = format!(
481 "{} got {percent}% of its build cache lookups from the cache{} over its last {} runs: its tasks' inputs \
482 differ from run to run.",
483 subject(key),
484 branch
485 .as_deref()
486 .map(|branch| format!(" on {branch}"))
487 .unwrap_or_default(),
488 writers.len()
489 );
490 Some(insight(
491 key,
492 "cold-cache",
493 Severity::Notice,
494 sentence,
495 InsightKind::ColdCache {
496 branch,
497 hits: total.hits,
498 lookups: total.lookups(),
499 },
500 writers.iter().map(|run| run.link()).collect(),
501 ))
502}
503
504fn waits(runs: &[Run], now: u64) -> Vec<RepositoryWaits> {
506 let mut weeks: BTreeMap<&str, [Vec<&Run>; 2]> = BTreeMap::new();
507 for run in runs {
508 let Some(started) = run.started_at.filter(|at| *at <= now) else {
509 continue;
510 };
511 let week = match now - started {
512 back if back < WEEK => 0,
513 back if back < 2 * WEEK => 1,
514 _ => continue,
515 };
516 weeks.entry(&run.repository).or_default()[week].push(run);
517 }
518 weeks
519 .into_iter()
520 .map(|(repository, [now, before])| RepositoryWaits {
521 repository: repository.to_owned(),
522 now: week_waits(&now),
523 before: week_waits(&before),
524 })
525 .collect()
526}
527
528fn week_waits(runs: &[&Run]) -> WeekWaits {
529 let warm = |run: &&&Run| run.runner_started_at.is_some_and(|at| at <= run.queued_at);
530 let of = |runs: Vec<&&Run>| {
531 let waited: Vec<u64> = runs.iter().filter_map(|run| run.waited()).collect();
532 let jobs = u32::try_from(waited.len()).unwrap_or(u32::MAX);
533 let spread = (!waited.is_empty()).then(|| spread(waited));
534 WaitSpread {
535 jobs,
536 p50: spread.as_ref().map(|spread| spread.p50),
537 p95: spread.as_ref().map(|spread| spread.p95),
538 }
539 };
540 WeekWaits {
541 all: of(runs.iter().collect()),
542 warm: of(runs.iter().filter(warm).collect()),
543 cold: of(runs.iter().filter(|run| !warm(run)).collect()),
544 }
545}
546
547fn waits_insight(waits: &RepositoryWaits) -> Option<Insight> {
548 if waits.now.all.jobs < WAITS_MIN_JOBS || waits.before.all.jobs < WAITS_MIN_JOBS {
549 return None;
550 }
551 let (before, now) = (waits.before.all.p95?, waits.now.all.p95?);
552 let threshold = (before + before * WAITS_PERCENT / 100).max(before + WAITS_SECONDS);
553 if now < threshold {
554 return None;
555 }
556 Some(Insight {
557 key: format!("waits:{}", waits.repository),
558 severity: Severity::Notice,
559 repository: waits.repository.clone(),
560 workflow: None,
561 job: None,
562 sentence: format!(
563 "{}: 1 job in 20 waits {} or more for a runner this week, up from {} the week before.",
564 waits.repository,
565 span(now),
566 span(before)
567 ),
568 kind: InsightKind::Waits { before, now },
569 runs: Vec::new(),
570 })
571}
572
573fn span(seconds: u64) -> String {
575 match seconds {
576 0..60 => format!("{seconds}s"),
577 60..3600 if seconds.is_multiple_of(60) => format!("{}m", seconds / 60),
578 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
579 _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
580 }
581}
582
583#[cfg(test)]
584mod tests;