Skip to main content

runnerd_core/
insights.rs

1//! Insights from the job history (ADR 0051): jobs that got slower and stayed so, jobs that fail and pass on a re-run,
2//! jobs whose memory nears their profile's cap, jobs that find almost nothing in the build cache, and how long each
3//! repository's jobs wait, this week against the last; and the daily digest. Pure: the daemon reads the history, the
4//! config and the clock and passes them in. It sends nothing and changes nothing.
5
6use 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
19/// The last runs a slower job's median is taken over; the baseline needs as many before them.
20const RECENT: usize = 5;
21/// A job is slower when its recent median is this share above its baseline's, in percent…
22const SLOWER_PERCENT: u64 = 20;
23/// …and at least this many seconds above it.
24const SLOWER_SECONDS: u64 = 30;
25
26/// The last runs whose memory peaks are read, and the fewest that say anything.
27const CAP_RUNS: usize = 10;
28const CAP_MIN_RUNS: usize = 5;
29/// A job is near its cap when the 95th percentile of its peaks is this share of it, in percent.
30const NEAR_CAP: u64 = 85;
31/// Runs at `NEAR_CAP` or more that make it a warning.
32const NEAR_CAP_WARNING: usize = 2;
33/// Flaky runs that make it a warning.
34const FLAKY_WARNING: u32 = 2;
35
36/// A repository's waits are an insight when this week's p95 is this share above last week's, in percent, and a minute
37/// above it, each week with this many jobs.
38const WAITS_PERCENT: u64 = 50;
39const WAITS_SECONDS: u64 = 60;
40const WAITS_MIN_JOBS: u32 = 5;
41
42/// One job the history closed, as insights read it. Times in milliseconds.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Run {
45    /// GitHub's job ID.
46    pub id: u64,
47    /// `owner/name`.
48    pub repository: String,
49    /// The workflow's name, when known.
50    pub workflow: Option<String>,
51    /// The job's name.
52    pub name: String,
53    /// The branch the run is for.
54    pub branch: Option<String>,
55    /// The job's page on GitHub.
56    pub url: Option<String>,
57    /// The workflow run.
58    pub run_id: u64,
59    /// The run's attempt, 1 for the first.
60    pub run_attempt: u32,
61    /// `Completed` or `Lost`.
62    pub state: JobState,
63    /// GitHub's conclusion, for a completed job.
64    pub conclusion: Option<String>,
65    /// When GitHub queued it.
66    pub queued_at: u64,
67    /// When it started.
68    pub started_at: Option<u64>,
69    /// When it finished, or was found lost.
70    pub finished_at: Option<u64>,
71    /// When its runner's container started: before `queued_at`, it found a warm runner.
72    pub runner_started_at: Option<u64>,
73    /// The profile its runner ran in.
74    pub profile: Option<String>,
75    /// Its sampled memory peak.
76    pub memory_peak: Option<u64>,
77    /// What the build cache served it, if its runner had the build cache.
78    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    /// Failed as `GET /v1/stats` counts failures: failed, timed out or lost.
91    fn failed(&self) -> bool {
92        self.state == JobState::Lost || self.failed_itself()
93    }
94
95    /// Failed or timed out: what the job did, not what became of its runner.
96    fn failed_itself(&self) -> bool {
97        self.state == JobState::Completed
98            && matches!(self.conclusion.as_deref(), Some("failure" | "timed_out"))
99    }
100
101    /// Seconds from start to finish.
102    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
122/// A job as insights tell jobs apart: repository, workflow and name.
123type 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
129/// The insights of the `days` before `now` (ms), and the waits of the 14 days before it. `runs` may hold more:
130/// only those finished by `now` count.
131pub 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
173/// The 24 hours before `now` (ms): what finished and failed, the insights that hold at `now` and did not 24 hours
174/// before, and the longest waits.
175pub 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
220/// `repository: workflow / job`, or `repository: job` when the workflow is not known.
221fn 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
252/// The median: the mean of the middle two for an even count.
253fn 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
264/// A job whose successful runs on `main` or `master`, or else its busiest branch, got slower and stayed so.
265fn 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    // The default branch when it has runs enough, so a pull request that adds tests is not a regression; else the
279    // most runs, and of equal counts the first by name.
280    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    // Every recent run slower than the baseline's median: a job that flips between two speeds, such as build cache
298    // hits and misses, moves its median with a few more slow runs in a row without getting slower.
299    if now < threshold || recent.iter().any(|(_, took)| *took <= before) {
300        return None;
301    }
302    // The first slow run after which no two runs in a row were fast.
303    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
339/// A job that failed and then passed on a re-run of the same run.
340fn 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    // Once in the window may be chance: a notice; twice, a warning.
367    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
382/// A job whose last runs' memory peaks near its profile's `memory`.
383fn 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    // Auto sizing deals with it, and one spike may be chance: a notice. The owner acts on the rest.
429    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
449/// A job's last runs that could write to the build cache, each with this many lookups at least…
450const COLD_RUNS: usize = 5;
451const COLD_MIN_RUNS: usize = 3;
452const COLD_MIN_LOOKUPS: u64 = 10;
453/// …found at most this share of their lookups, in percent: the runs before them stored what they built, so the
454/// next should find most of it.
455const 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
504/// Each repository's waits in the 7 days before `now` (ms) and the 7 before those.
505fn 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
573/// A duration as a sentence reads it: `45s`, `4m 30s`, `1h 5m`.
574fn 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;