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 savings: None,
226 }
227}
228
229fn subject((repository, workflow, name): JobKey<'_>) -> String {
231 match workflow {
232 Some(workflow) => format!("{repository}: {workflow} / {name}"),
233 None => format!("{repository}: {name}"),
234 }
235}
236
237fn key_text(kind: &str, (repository, workflow, name): JobKey<'_>) -> String {
238 format!("{kind}:{repository}/{}/{name}", workflow.unwrap_or(""))
239}
240
241fn insight(
242 key: JobKey<'_>,
243 kind: &str,
244 severity: Severity,
245 sentence: String,
246 found: InsightKind,
247 runs: Vec<RunLink>,
248) -> Insight {
249 Insight {
250 key: key_text(kind, key),
251 severity,
252 repository: key.0.to_owned(),
253 workflow: key.1.map(str::to_owned),
254 job: Some(key.2.to_owned()),
255 sentence,
256 kind: found,
257 runs,
258 }
259}
260
261fn median(values: &[u64]) -> u64 {
263 let mut values = values.to_vec();
264 values.sort_unstable();
265 let middle = values.len() / 2;
266 match values.len() {
267 0 => 0,
268 len if len % 2 == 1 => values[middle],
269 _ => u64::midpoint(values[middle - 1], values[middle]),
270 }
271}
272
273fn slower(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
275 let timed_on = |branch: &str| -> Vec<(&Run, u64)> {
276 runs.iter()
277 .filter(|run| run.succeeded() && run.branch.as_deref() == Some(branch))
278 .filter_map(|run| Some((*run, run.took()?)))
279 .collect()
280 };
281 let mut branches: BTreeMap<&str, usize> = BTreeMap::new();
282 for run in runs.iter().filter(|run| run.succeeded()) {
283 if let Some(branch) = &run.branch {
284 *branches.entry(branch).or_default() += 1;
285 }
286 }
287 let busiest = branches
290 .iter()
291 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
292 .map(|(branch, _)| *branch)?;
293 let (branch, timed) = ["main", "master"]
294 .into_iter()
295 .map(|branch| (branch, timed_on(branch)))
296 .find(|(_, timed)| timed.len() >= 2 * RECENT)
297 .unwrap_or_else(|| (busiest, timed_on(busiest)));
298 if timed.len() < 2 * RECENT {
299 return None;
300 }
301 let (baseline, recent) = timed.split_at(timed.len() - RECENT);
302 let took = |runs: &[(&Run, u64)]| runs.iter().map(|(_, took)| *took).collect::<Vec<_>>();
303 let before = median(&took(baseline));
304 let now = median(&took(recent));
305 let threshold = (before + before * SLOWER_PERCENT / 100).max(before + SLOWER_SECONDS);
306 if now < threshold || recent.iter().any(|(_, took)| *took <= before) {
309 return None;
310 }
311 let mut since = None;
313 let mut fast_in_a_row = 0;
314 for (run, took) in &timed {
315 if *took >= threshold {
316 fast_in_a_row = 0;
317 since = since.or(Some(*run));
318 } else {
319 fast_in_a_row += 1;
320 if fast_in_a_row >= 2 {
321 since = None;
322 }
323 }
324 }
325 let since = since?;
326 let sentence = format!(
327 "{} on {branch} takes {} now, up from {}.",
328 subject(key),
329 span(now),
330 span(before)
331 );
332 let links = recent.iter().rev().map(|(run, _)| run.link()).collect();
333 Some(insight(
334 key,
335 "slower",
336 Severity::Warning,
337 sentence,
338 InsightKind::Slower {
339 branch: Some(branch.to_owned()),
340 before,
341 now,
342 since: Some(since.link()),
343 },
344 links,
345 ))
346}
347
348fn flaky(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
350 let mut by_run: BTreeMap<u64, Vec<&Run>> = BTreeMap::new();
351 for run in runs {
352 by_run.entry(run.run_id).or_default().push(run);
353 }
354 let mut failed_first: Vec<&Run> = by_run
355 .values()
356 .filter_map(|attempts| {
357 attempts.iter().copied().find(|failed| {
358 failed.failed_itself()
359 && attempts
360 .iter()
361 .any(|later| later.run_attempt > failed.run_attempt && later.succeeded())
362 })
363 })
364 .collect();
365 if failed_first.is_empty() {
366 return None;
367 }
368 failed_first.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
369 let flaky = u32::try_from(failed_first.len()).unwrap_or(u32::MAX);
370 let total = u32::try_from(by_run.len()).unwrap_or(u32::MAX);
371 let sentence = format!(
372 "{} failed and then passed on a re-run in {flaky} of its {total} runs.",
373 subject(key)
374 );
375 let severity = if flaky >= FLAKY_WARNING {
377 Severity::Warning
378 } else {
379 Severity::Notice
380 };
381 Some(insight(
382 key,
383 "flaky",
384 severity,
385 sentence,
386 InsightKind::Flaky { flaky, total },
387 failed_first.iter().take(5).map(|run| run.link()).collect(),
388 ))
389}
390
391fn near_cap(key: JobKey<'_>, runs: &[&Run], config: &Config) -> Option<Insight> {
394 let measured = runs
395 .iter()
396 .rev()
397 .filter(|run| run.memory_peak.is_some() && run.profile.is_some());
398 let name = measured.clone().next()?.profile.as_deref()?;
399 let measured: Vec<&Run> = measured
400 .filter(|run| run.profile.as_deref() == Some(name))
401 .take(CAP_RUNS)
402 .copied()
403 .collect();
404 if measured.len() < CAP_MIN_RUNS {
405 return None;
406 }
407 let profile = config.profiles.get(name)?;
408 let cap = profile.memory.0;
409 let peaks: Vec<u64> = measured.iter().filter_map(|run| run.memory_peak).collect();
410 let near = |peak: &u64| peak.saturating_mul(100) >= cap.saturating_mul(NEAR_CAP);
411 let peak = spread(peaks.clone()).p95;
412 if cap == 0 || !near(&peak) {
413 return None;
414 }
415 let near_runs = peaks.iter().filter(|peak| near(peak)).count();
416 let auto = if !config.sizing.auto {
417 AutoNote::Off
418 } else if profile
419 .max_memory
420 .is_some_and(|max| profile.memory.0 >= max.0)
421 {
422 AutoNote::AtMax
423 } else {
424 AutoNote::Raises
425 };
426 let what = match auto {
427 AutoNote::Raises => format!("Auto sizing raises {name} when a run meets the cap."),
428 AutoNote::AtMax => {
429 format!("{name} is at its max_memory, so auto sizing cannot raise it.")
430 }
431 AutoNote::Off => format!("Auto sizing is off: raise {name}'s memory, or turn it on."),
432 };
433 let sentence = format!(
434 "{} peaks at {}, {}% of {name}'s {}. {what}",
435 subject(key),
436 ByteSize(peak),
437 peak.saturating_mul(100) / cap,
438 ByteSize(cap)
439 );
440 let severity = if auto == AutoNote::Raises || near_runs < NEAR_CAP_WARNING {
442 Severity::Notice
443 } else {
444 Severity::Warning
445 };
446 Some(insight(
447 key,
448 "near-cap",
449 severity,
450 sentence,
451 InsightKind::NearCap {
452 profile: name.to_owned(),
453 peak,
454 cap,
455 auto,
456 },
457 measured.iter().take(5).map(|run| run.link()).collect(),
458 ))
459}
460
461const COLD_RUNS: usize = 5;
463const COLD_MIN_RUNS: usize = 3;
464const COLD_MIN_LOOKUPS: u64 = 10;
465const COLD_PERCENT: u64 = 10;
468
469fn cold_cache(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
470 let writers: Vec<&Run> = runs
471 .iter()
472 .rev()
473 .filter(|run| {
474 run.build_cache
475 .is_some_and(|cache| cache.writer && cache.lookups() >= COLD_MIN_LOOKUPS)
476 })
477 .take(COLD_RUNS)
478 .copied()
479 .collect();
480 if writers.len() < COLD_MIN_RUNS {
481 return None;
482 }
483 let mut total = api::BuildCacheCounts::default();
484 for run in &writers {
485 total.add(&run.build_cache.unwrap_or_default());
486 }
487 let percent = total.hit_percent()?;
488 if percent > COLD_PERCENT {
489 return None;
490 }
491 let branch = writers.first()?.branch.clone();
492 let sentence = format!(
493 "{} got {percent}% of its build cache lookups from the cache{} over its last {} runs: its tasks' inputs \
494 differ from run to run.",
495 subject(key),
496 branch
497 .as_deref()
498 .map(|branch| format!(" on {branch}"))
499 .unwrap_or_default(),
500 writers.len()
501 );
502 Some(insight(
503 key,
504 "cold-cache",
505 Severity::Notice,
506 sentence,
507 InsightKind::ColdCache {
508 branch,
509 hits: total.hits,
510 lookups: total.lookups(),
511 },
512 writers.iter().map(|run| run.link()).collect(),
513 ))
514}
515
516fn throttled(window: &[&Run]) -> Vec<Insight> {
518 let mut repositories: BTreeMap<&str, Vec<&Run>> = BTreeMap::new();
519 for run in window {
520 repositories.entry(&run.repository).or_default().push(run);
521 }
522 repositories
523 .into_iter()
524 .filter_map(|(repository, mut runs)| {
525 runs.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
526 let hit: Vec<&Run> = runs
527 .iter()
528 .filter(|run| run.sock_throttled.is_some_and(|count| count > 0))
529 .copied()
530 .collect();
531 let throttled = u32::try_from(hit.len()).unwrap_or(u32::MAX);
532 if throttled < THROTTLED_MIN_RUNS {
533 return None;
534 }
535 let total = u32::try_from(runs.len()).unwrap_or(u32::MAX);
536 Some(Insight {
537 key: format!("throttled:{repository}"),
538 severity: Severity::Warning,
539 repository: repository.to_owned(),
540 workflow: None,
541 job: None,
542 sentence: format!(
543 "{repository}: memory pressure slowed the downloads of {throttled} of its {total} jobs: the \
544 host, or a job at its cap, was short of memory."
545 ),
546 kind: InsightKind::Throttled { throttled, total },
547 runs: hit.iter().take(5).map(|run| run.link()).collect(),
548 })
549 })
550 .collect()
551}
552
553fn waits(runs: &[Run], now: u64) -> Vec<RepositoryWaits> {
555 let mut weeks: BTreeMap<&str, [Vec<&Run>; 2]> = BTreeMap::new();
556 for run in runs {
557 let Some(started) = run.started_at.filter(|at| *at <= now) else {
558 continue;
559 };
560 let week = match now - started {
561 back if back < WEEK => 0,
562 back if back < 2 * WEEK => 1,
563 _ => continue,
564 };
565 weeks.entry(&run.repository).or_default()[week].push(run);
566 }
567 weeks
568 .into_iter()
569 .map(|(repository, [now, before])| RepositoryWaits {
570 repository: repository.to_owned(),
571 now: week_waits(&now),
572 before: week_waits(&before),
573 })
574 .collect()
575}
576
577fn week_waits(runs: &[&Run]) -> WeekWaits {
578 let warm = |run: &&&Run| run.runner_started_at.is_some_and(|at| at <= run.queued_at);
579 let of = |runs: Vec<&&Run>| {
580 let waited: Vec<u64> = runs.iter().filter_map(|run| run.waited()).collect();
581 let jobs = u32::try_from(waited.len()).unwrap_or(u32::MAX);
582 let spread = (!waited.is_empty()).then(|| spread(waited));
583 WaitSpread {
584 jobs,
585 p50: spread.as_ref().map(|spread| spread.p50),
586 p95: spread.as_ref().map(|spread| spread.p95),
587 }
588 };
589 WeekWaits {
590 all: of(runs.iter().collect()),
591 warm: of(runs.iter().filter(warm).collect()),
592 cold: of(runs.iter().filter(|run| !warm(run)).collect()),
593 }
594}
595
596fn waits_insight(waits: &RepositoryWaits) -> Option<Insight> {
597 if waits.now.all.jobs < WAITS_MIN_JOBS || waits.before.all.jobs < WAITS_MIN_JOBS {
598 return None;
599 }
600 let (before, now) = (waits.before.all.p95?, waits.now.all.p95?);
601 let threshold = (before + before * WAITS_PERCENT / 100).max(before + WAITS_SECONDS);
602 if now < threshold {
603 return None;
604 }
605 Some(Insight {
606 key: format!("waits:{}", waits.repository),
607 severity: Severity::Notice,
608 repository: waits.repository.clone(),
609 workflow: None,
610 job: None,
611 sentence: if waits.now.all.jobs < WAITS_ONE_IN_20 {
612 format!(
613 "{}: the longest wait for a runner this week is {}, up from {} the week before.",
614 waits.repository,
615 span(now),
616 span(before)
617 )
618 } else {
619 format!(
620 "{}: 1 job in 20 waits {} or more for a runner this week, up from {} the week before.",
621 waits.repository,
622 span(now),
623 span(before)
624 )
625 },
626 kind: InsightKind::Waits { before, now },
627 runs: Vec::new(),
628 })
629}
630
631fn span(seconds: u64) -> String {
633 match seconds {
634 0..60 => format!("{seconds}s"),
635 60..3600 if seconds.is_multiple_of(60) => format!("{}m", seconds / 60),
636 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
637 _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
638 }
639}
640
641#[cfg(test)]
642mod tests;