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> {
393 let measured = runs
394 .iter()
395 .rev()
396 .filter(|run| run.memory_peak.is_some() && run.profile.is_some());
397 let name = measured.clone().next()?.profile.as_deref()?;
398 let measured: Vec<&Run> = measured
399 .filter(|run| run.profile.as_deref() == Some(name))
400 .take(CAP_RUNS)
401 .copied()
402 .collect();
403 if measured.len() < CAP_MIN_RUNS {
404 return None;
405 }
406 let profile = config.profiles.get(name)?;
407 let cap = profile.memory.0;
408 let peaks: Vec<u64> = measured.iter().filter_map(|run| run.memory_peak).collect();
409 let near = |peak: &u64| peak.saturating_mul(100) >= cap.saturating_mul(NEAR_CAP);
410 let peak = spread(peaks.clone()).p95;
411 if cap == 0 || !near(&peak) {
412 return None;
413 }
414 let near_runs = peaks.iter().filter(|peak| near(peak)).count();
415 let auto = if !config.sizing.auto {
416 AutoNote::Off
417 } else if profile
418 .max_memory
419 .is_some_and(|max| profile.memory.0 >= max.0)
420 {
421 AutoNote::AtMax
422 } else {
423 AutoNote::Raises
424 };
425 let what = match auto {
426 AutoNote::Raises => format!("Auto sizing raises {name} when a run meets the cap."),
427 AutoNote::AtMax => {
428 format!("{name} is at its max_memory, so auto sizing cannot raise it.")
429 }
430 AutoNote::Off => format!("Auto sizing is off: raise {name}'s memory, or turn it on."),
431 };
432 let sentence = format!(
433 "{} peaks at {}, {}% of {name}'s {}. {what}",
434 subject(key),
435 ByteSize(peak),
436 peak.saturating_mul(100) / cap,
437 ByteSize(cap)
438 );
439 let severity = if auto == AutoNote::Raises || near_runs < NEAR_CAP_WARNING {
441 Severity::Notice
442 } else {
443 Severity::Warning
444 };
445 Some(insight(
446 key,
447 "near-cap",
448 severity,
449 sentence,
450 InsightKind::NearCap {
451 profile: name.to_owned(),
452 peak,
453 cap,
454 auto,
455 },
456 measured.iter().take(5).map(|run| run.link()).collect(),
457 ))
458}
459
460const COLD_RUNS: usize = 5;
462const COLD_MIN_RUNS: usize = 3;
463const COLD_MIN_LOOKUPS: u64 = 10;
464const COLD_PERCENT: u64 = 10;
467
468fn cold_cache(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
469 let writers: Vec<&Run> = runs
470 .iter()
471 .rev()
472 .filter(|run| {
473 run.build_cache
474 .is_some_and(|cache| cache.writer && cache.lookups() >= COLD_MIN_LOOKUPS)
475 })
476 .take(COLD_RUNS)
477 .copied()
478 .collect();
479 if writers.len() < COLD_MIN_RUNS {
480 return None;
481 }
482 let mut total = api::BuildCacheCounts::default();
483 for run in &writers {
484 total.add(&run.build_cache.unwrap_or_default());
485 }
486 let percent = total.hit_percent()?;
487 if percent > COLD_PERCENT {
488 return None;
489 }
490 let branch = writers.first()?.branch.clone();
491 let sentence = format!(
492 "{} got {percent}% of its build cache lookups from the cache{} over its last {} runs: its tasks' inputs \
493 differ from run to run.",
494 subject(key),
495 branch
496 .as_deref()
497 .map(|branch| format!(" on {branch}"))
498 .unwrap_or_default(),
499 writers.len()
500 );
501 Some(insight(
502 key,
503 "cold-cache",
504 Severity::Notice,
505 sentence,
506 InsightKind::ColdCache {
507 branch,
508 hits: total.hits,
509 lookups: total.lookups(),
510 },
511 writers.iter().map(|run| run.link()).collect(),
512 ))
513}
514
515fn throttled(window: &[&Run]) -> Vec<Insight> {
517 let mut repositories: BTreeMap<&str, Vec<&Run>> = BTreeMap::new();
518 for run in window {
519 repositories.entry(&run.repository).or_default().push(run);
520 }
521 repositories
522 .into_iter()
523 .filter_map(|(repository, mut runs)| {
524 runs.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
525 let hit: Vec<&Run> = runs
526 .iter()
527 .filter(|run| run.sock_throttled.is_some_and(|count| count > 0))
528 .copied()
529 .collect();
530 let throttled = u32::try_from(hit.len()).unwrap_or(u32::MAX);
531 if throttled < THROTTLED_MIN_RUNS {
532 return None;
533 }
534 let total = u32::try_from(runs.len()).unwrap_or(u32::MAX);
535 Some(Insight {
536 key: format!("throttled:{repository}"),
537 severity: Severity::Warning,
538 repository: repository.to_owned(),
539 workflow: None,
540 job: None,
541 sentence: format!(
542 "{repository}: memory pressure slowed the downloads of {throttled} of its {total} jobs: the \
543 host, or a job at its cap, was short of memory."
544 ),
545 kind: InsightKind::Throttled { throttled, total },
546 runs: hit.iter().take(5).map(|run| run.link()).collect(),
547 })
548 })
549 .collect()
550}
551
552fn waits(runs: &[Run], now: u64) -> Vec<RepositoryWaits> {
554 let mut weeks: BTreeMap<&str, [Vec<&Run>; 2]> = BTreeMap::new();
555 for run in runs {
556 let Some(started) = run.started_at.filter(|at| *at <= now) else {
557 continue;
558 };
559 let week = match now - started {
560 back if back < WEEK => 0,
561 back if back < 2 * WEEK => 1,
562 _ => continue,
563 };
564 weeks.entry(&run.repository).or_default()[week].push(run);
565 }
566 weeks
567 .into_iter()
568 .map(|(repository, [now, before])| RepositoryWaits {
569 repository: repository.to_owned(),
570 now: week_waits(&now),
571 before: week_waits(&before),
572 })
573 .collect()
574}
575
576fn week_waits(runs: &[&Run]) -> WeekWaits {
577 let warm = |run: &&&Run| run.runner_started_at.is_some_and(|at| at <= run.queued_at);
578 let of = |runs: Vec<&&Run>| {
579 let waited: Vec<u64> = runs.iter().filter_map(|run| run.waited()).collect();
580 let jobs = u32::try_from(waited.len()).unwrap_or(u32::MAX);
581 let spread = (!waited.is_empty()).then(|| spread(waited));
582 WaitSpread {
583 jobs,
584 p50: spread.as_ref().map(|spread| spread.p50),
585 p95: spread.as_ref().map(|spread| spread.p95),
586 }
587 };
588 WeekWaits {
589 all: of(runs.iter().collect()),
590 warm: of(runs.iter().filter(warm).collect()),
591 cold: of(runs.iter().filter(|run| !warm(run)).collect()),
592 }
593}
594
595fn waits_insight(waits: &RepositoryWaits) -> Option<Insight> {
596 if waits.now.all.jobs < WAITS_MIN_JOBS || waits.before.all.jobs < WAITS_MIN_JOBS {
597 return None;
598 }
599 let (before, now) = (waits.before.all.p95?, waits.now.all.p95?);
600 let threshold = (before + before * WAITS_PERCENT / 100).max(before + WAITS_SECONDS);
601 if now < threshold {
602 return None;
603 }
604 Some(Insight {
605 key: format!("waits:{}", waits.repository),
606 severity: Severity::Notice,
607 repository: waits.repository.clone(),
608 workflow: None,
609 job: None,
610 sentence: if waits.now.all.jobs < WAITS_ONE_IN_20 {
611 format!(
612 "{}: the longest wait for a runner this week is {}, up from {} the week before.",
613 waits.repository,
614 span(now),
615 span(before)
616 )
617 } else {
618 format!(
619 "{}: 1 job in 20 waits {} or more for a runner this week, up from {} the week before.",
620 waits.repository,
621 span(now),
622 span(before)
623 )
624 },
625 kind: InsightKind::Waits { before, now },
626 runs: Vec::new(),
627 })
628}
629
630fn span(seconds: u64) -> String {
632 match seconds {
633 0..60 => format!("{seconds}s"),
634 60..3600 if seconds.is_multiple_of(60) => format!("{}m", seconds / 60),
635 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
636 _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
637 }
638}
639
640#[cfg(test)]
641mod tests;