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