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, and how long each repository's jobs wait, this week against the
3//! last; and the daily digest. Pure: the daemon reads the history, the config and the clock and passes them in. It
4//! 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}
78
79impl Run {
80    fn finished(&self) -> u64 {
81        self.finished_at.unwrap_or(0)
82    }
83
84    fn succeeded(&self) -> bool {
85        self.state == JobState::Completed && self.conclusion.as_deref() == Some("success")
86    }
87
88    /// Failed as `GET /v1/stats` counts failures: failed, timed out or lost.
89    fn failed(&self) -> bool {
90        self.state == JobState::Lost || self.failed_itself()
91    }
92
93    /// Failed or timed out: what the job did, not what became of its runner.
94    fn failed_itself(&self) -> bool {
95        self.state == JobState::Completed
96            && matches!(self.conclusion.as_deref(), Some("failure" | "timed_out"))
97    }
98
99    /// Seconds from start to finish.
100    fn took(&self) -> Option<u64> {
101        Some(self.finished_at?.saturating_sub(self.started_at?) / SECOND)
102    }
103
104    fn waited(&self) -> Option<u64> {
105        Some(self.started_at?.saturating_sub(self.queued_at) / SECOND)
106    }
107
108    fn link(&self) -> RunLink {
109        RunLink {
110            job: self.id,
111            run_id: self.run_id,
112            run_attempt: self.run_attempt,
113            branch: self.branch.clone(),
114            url: self.url.clone(),
115            at: self.finished() / SECOND,
116        }
117    }
118}
119
120/// A job as insights tell jobs apart: repository, workflow and name.
121type JobKey<'run> = (&'run str, Option<&'run str>, &'run str);
122
123fn key_of(run: &Run) -> JobKey<'_> {
124    (&run.repository, run.workflow.as_deref(), &run.name)
125}
126
127/// The insights of the `days` before `now` (ms), and the waits of the 14 days before it. `runs` may hold more:
128/// only those finished by `now` count.
129pub fn insights(runs: &[Run], config: &Config, days: u32, now: u64) -> Insights {
130    let since = now.saturating_sub(u64::from(days) * DAY);
131    let window: Vec<&Run> = runs
132        .iter()
133        .filter(|run| {
134            matches!(run.state, JobState::Completed | JobState::Lost)
135                && run
136                    .finished_at
137                    .is_some_and(|at| (since..=now).contains(&at))
138        })
139        .collect();
140    let mut jobs: BTreeMap<JobKey<'_>, Vec<&Run>> = BTreeMap::new();
141    for run in &window {
142        jobs.entry(key_of(run)).or_default().push(run);
143    }
144    let mut found = Vec::new();
145    for (key, runs) in &mut jobs {
146        runs.sort_by_key(|run| (run.finished(), run.id));
147        found.extend(slower(*key, runs));
148        found.extend(flaky(*key, runs));
149        found.extend(near_cap(*key, runs, config));
150    }
151    let waits = waits(runs, now);
152    found.extend(waits.iter().filter_map(waits_insight));
153    found.sort_by(|a, b| {
154        (a.severity, &a.repository, &a.workflow, &a.job, &a.key).cmp(&(
155            b.severity,
156            &b.repository,
157            &b.workflow,
158            &b.job,
159            &b.key,
160        ))
161    });
162    Insights {
163        since: since / SECOND,
164        until: now / SECOND,
165        insights: found,
166        waits,
167    }
168}
169
170/// The 24 hours before `now` (ms): what finished and failed, the insights that hold at `now` and did not 24 hours
171/// before, and the longest waits.
172pub fn digest(runs: &[Run], config: &Config, now: u64) -> Digest {
173    let since = now.saturating_sub(DAY);
174    let finished: Vec<&Run> = runs
175        .iter()
176        .filter(|run| {
177            matches!(run.state, JobState::Completed | JobState::Lost)
178                && run.finished_at.is_some_and(|at| at > since && at <= now)
179        })
180        .collect();
181    let before: BTreeSet<String> = insights(runs, config, api::INSIGHT_DAYS, since)
182        .insights
183        .into_iter()
184        .map(|insight| insight.key)
185        .collect();
186    let new = insights(runs, config, api::INSIGHT_DAYS, now)
187        .insights
188        .into_iter()
189        .filter(|insight| !before.contains(&insight.key))
190        .collect();
191    let mut started: Vec<&Run> = runs
192        .iter()
193        .filter(|run| run.started_at.is_some_and(|at| at > since && at <= now))
194        .collect();
195    started.sort_by_key(|run| (std::cmp::Reverse(run.waited()), run.id));
196    Digest {
197        since: since / SECOND,
198        until: now / SECOND,
199        finished: finished.len() as u64,
200        failed: finished.iter().filter(|run| run.failed()).count() as u64,
201        new,
202        waits: started
203            .into_iter()
204            .take(3)
205            .map(|run| LongWait {
206                job: run.id,
207                repository: run.repository.clone(),
208                workflow: run.workflow.clone(),
209                name: run.name.clone(),
210                waited: run.waited().unwrap_or(0),
211                url: run.url.clone(),
212            })
213            .collect(),
214    }
215}
216
217/// `repository: workflow / job`, or `repository: job` when the workflow is not known.
218fn subject((repository, workflow, name): JobKey<'_>) -> String {
219    match workflow {
220        Some(workflow) => format!("{repository}: {workflow} / {name}"),
221        None => format!("{repository}: {name}"),
222    }
223}
224
225fn key_text(kind: &str, (repository, workflow, name): JobKey<'_>) -> String {
226    format!("{kind}:{repository}/{}/{name}", workflow.unwrap_or(""))
227}
228
229fn insight(
230    key: JobKey<'_>,
231    kind: &str,
232    severity: Severity,
233    sentence: String,
234    found: InsightKind,
235    runs: Vec<RunLink>,
236) -> Insight {
237    Insight {
238        key: key_text(kind, key),
239        severity,
240        repository: key.0.to_owned(),
241        workflow: key.1.map(str::to_owned),
242        job: Some(key.2.to_owned()),
243        sentence,
244        kind: found,
245        runs,
246    }
247}
248
249/// The median: the mean of the middle two for an even count.
250fn median(values: &[u64]) -> u64 {
251    let mut values = values.to_vec();
252    values.sort_unstable();
253    let middle = values.len() / 2;
254    match values.len() {
255        0 => 0,
256        len if len % 2 == 1 => values[middle],
257        _ => u64::midpoint(values[middle - 1], values[middle]),
258    }
259}
260
261/// A job whose successful runs on `main` or `master`, or else its busiest branch, got slower and stayed so.
262fn slower(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
263    let timed_on = |branch: &str| -> Vec<(&Run, u64)> {
264        runs.iter()
265            .filter(|run| run.succeeded() && run.branch.as_deref() == Some(branch))
266            .filter_map(|run| Some((*run, run.took()?)))
267            .collect()
268    };
269    let mut branches: BTreeMap<&str, usize> = BTreeMap::new();
270    for run in runs.iter().filter(|run| run.succeeded()) {
271        if let Some(branch) = &run.branch {
272            *branches.entry(branch).or_default() += 1;
273        }
274    }
275    // The default branch when it has runs enough, so a pull request that adds tests is not a regression; else the
276    // most runs, and of equal counts the first by name.
277    let busiest = branches
278        .iter()
279        .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
280        .map(|(branch, _)| *branch)?;
281    let (branch, timed) = ["main", "master"]
282        .into_iter()
283        .map(|branch| (branch, timed_on(branch)))
284        .find(|(_, timed)| timed.len() >= 2 * RECENT)
285        .unwrap_or_else(|| (busiest, timed_on(busiest)));
286    if timed.len() < 2 * RECENT {
287        return None;
288    }
289    let (baseline, recent) = timed.split_at(timed.len() - RECENT);
290    let took = |runs: &[(&Run, u64)]| runs.iter().map(|(_, took)| *took).collect::<Vec<_>>();
291    let before = median(&took(baseline));
292    let now = median(&took(recent));
293    let threshold = (before + before * SLOWER_PERCENT / 100).max(before + SLOWER_SECONDS);
294    if now < threshold {
295        return None;
296    }
297    // The first slow run after which no two runs in a row were fast.
298    let mut since = None;
299    let mut fast_in_a_row = 0;
300    for (run, took) in &timed {
301        if *took >= threshold {
302            fast_in_a_row = 0;
303            since = since.or(Some(*run));
304        } else {
305            fast_in_a_row += 1;
306            if fast_in_a_row >= 2 {
307                since = None;
308            }
309        }
310    }
311    let since = since?;
312    let sentence = format!(
313        "{} on {branch} takes {} now, up from {}.",
314        subject(key),
315        span(now),
316        span(before)
317    );
318    let links = recent.iter().rev().map(|(run, _)| run.link()).collect();
319    Some(insight(
320        key,
321        "slower",
322        Severity::Warning,
323        sentence,
324        InsightKind::Slower {
325            branch: Some(branch.to_owned()),
326            before,
327            now,
328            since: Some(since.link()),
329        },
330        links,
331    ))
332}
333
334/// A job that failed and then passed on a re-run of the same run.
335fn flaky(key: JobKey<'_>, runs: &[&Run]) -> Option<Insight> {
336    let mut by_run: BTreeMap<u64, Vec<&Run>> = BTreeMap::new();
337    for run in runs {
338        by_run.entry(run.run_id).or_default().push(run);
339    }
340    let mut failed_first: Vec<&Run> = by_run
341        .values()
342        .filter_map(|attempts| {
343            attempts.iter().copied().find(|failed| {
344                failed.failed_itself()
345                    && attempts
346                        .iter()
347                        .any(|later| later.run_attempt > failed.run_attempt && later.succeeded())
348            })
349        })
350        .collect();
351    if failed_first.is_empty() {
352        return None;
353    }
354    failed_first.sort_by_key(|run| std::cmp::Reverse((run.finished(), run.id)));
355    let flaky = u32::try_from(failed_first.len()).unwrap_or(u32::MAX);
356    let total = u32::try_from(by_run.len()).unwrap_or(u32::MAX);
357    let sentence = format!(
358        "{} failed and then passed on a re-run in {flaky} of its {total} runs.",
359        subject(key)
360    );
361    // Once in the window may be chance: a notice; twice, a warning.
362    let severity = if flaky >= FLAKY_WARNING {
363        Severity::Warning
364    } else {
365        Severity::Notice
366    };
367    Some(insight(
368        key,
369        "flaky",
370        severity,
371        sentence,
372        InsightKind::Flaky { flaky, total },
373        failed_first.iter().take(5).map(|run| run.link()).collect(),
374    ))
375}
376
377/// A job whose last runs' memory peaks near its profile's `memory`.
378fn near_cap(key: JobKey<'_>, runs: &[&Run], config: &Config) -> Option<Insight> {
379    let measured: Vec<&Run> = runs
380        .iter()
381        .rev()
382        .filter(|run| run.memory_peak.is_some() && run.profile.is_some())
383        .take(CAP_RUNS)
384        .copied()
385        .collect();
386    if measured.len() < CAP_MIN_RUNS {
387        return None;
388    }
389    let name = measured.first()?.profile.as_deref()?;
390    let profile = config.profiles.get(name)?;
391    let cap = profile.memory.0;
392    let peaks: Vec<u64> = measured.iter().filter_map(|run| run.memory_peak).collect();
393    let near = |peak: &u64| peak.saturating_mul(100) >= cap.saturating_mul(NEAR_CAP);
394    let peak = spread(peaks.clone()).p95;
395    if cap == 0 || !near(&peak) {
396        return None;
397    }
398    let near_runs = peaks.iter().filter(|peak| near(peak)).count();
399    let auto = if !config.sizing.auto {
400        AutoNote::Off
401    } else if profile
402        .max_memory
403        .is_some_and(|max| profile.memory.0 >= max.0)
404    {
405        AutoNote::AtMax
406    } else {
407        AutoNote::Raises
408    };
409    let what = match auto {
410        AutoNote::Raises => format!("Auto sizing raises {name} when a run meets the cap."),
411        AutoNote::AtMax => {
412            format!("{name} is at its max_memory, so auto sizing cannot raise it.")
413        }
414        AutoNote::Off => format!("Auto sizing is off: raise {name}'s memory, or turn it on."),
415    };
416    let sentence = format!(
417        "{} peaks at {}, {}% of {name}'s {}. {what}",
418        subject(key),
419        ByteSize(peak),
420        peak.saturating_mul(100) / cap,
421        ByteSize(cap)
422    );
423    // Auto sizing deals with it, and one spike may be chance: a notice. The owner acts on the rest.
424    let severity = if auto == AutoNote::Raises || near_runs < NEAR_CAP_WARNING {
425        Severity::Notice
426    } else {
427        Severity::Warning
428    };
429    Some(insight(
430        key,
431        "near-cap",
432        severity,
433        sentence,
434        InsightKind::NearCap {
435            profile: name.to_owned(),
436            peak,
437            cap,
438            auto,
439        },
440        measured.iter().take(5).map(|run| run.link()).collect(),
441    ))
442}
443
444/// Each repository's waits in the 7 days before `now` (ms) and the 7 before those.
445fn waits(runs: &[Run], now: u64) -> Vec<RepositoryWaits> {
446    let mut weeks: BTreeMap<&str, [Vec<&Run>; 2]> = BTreeMap::new();
447    for run in runs {
448        let Some(started) = run.started_at.filter(|at| *at <= now) else {
449            continue;
450        };
451        let week = match now - started {
452            back if back < WEEK => 0,
453            back if back < 2 * WEEK => 1,
454            _ => continue,
455        };
456        weeks.entry(&run.repository).or_default()[week].push(run);
457    }
458    weeks
459        .into_iter()
460        .map(|(repository, [now, before])| RepositoryWaits {
461            repository: repository.to_owned(),
462            now: week_waits(&now),
463            before: week_waits(&before),
464        })
465        .collect()
466}
467
468fn week_waits(runs: &[&Run]) -> WeekWaits {
469    let warm = |run: &&&Run| run.runner_started_at.is_some_and(|at| at <= run.queued_at);
470    let of = |runs: Vec<&&Run>| {
471        let waited: Vec<u64> = runs.iter().filter_map(|run| run.waited()).collect();
472        let jobs = u32::try_from(waited.len()).unwrap_or(u32::MAX);
473        let spread = (!waited.is_empty()).then(|| spread(waited));
474        WaitSpread {
475            jobs,
476            p50: spread.as_ref().map(|spread| spread.p50),
477            p95: spread.as_ref().map(|spread| spread.p95),
478        }
479    };
480    WeekWaits {
481        all: of(runs.iter().collect()),
482        warm: of(runs.iter().filter(warm).collect()),
483        cold: of(runs.iter().filter(|run| !warm(run)).collect()),
484    }
485}
486
487fn waits_insight(waits: &RepositoryWaits) -> Option<Insight> {
488    if waits.now.all.jobs < WAITS_MIN_JOBS || waits.before.all.jobs < WAITS_MIN_JOBS {
489        return None;
490    }
491    let (before, now) = (waits.before.all.p95?, waits.now.all.p95?);
492    let threshold = (before + before * WAITS_PERCENT / 100).max(before + WAITS_SECONDS);
493    if now < threshold {
494        return None;
495    }
496    Some(Insight {
497        key: format!("waits:{}", waits.repository),
498        severity: Severity::Notice,
499        repository: waits.repository.clone(),
500        workflow: None,
501        job: None,
502        sentence: format!(
503            "{}: 1 job in 20 waits {} or more for a runner this week, up from {} the week before.",
504            waits.repository,
505            span(now),
506            span(before)
507        ),
508        kind: InsightKind::Waits { before, now },
509        runs: Vec::new(),
510    })
511}
512
513/// A duration as a sentence reads it: `45s`, `4m 30s`, `1h 5m`.
514fn span(seconds: u64) -> String {
515    match seconds {
516        0..60 => format!("{seconds}s"),
517        60..3600 if seconds.is_multiple_of(60) => format!("{}m", seconds / 60),
518        60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
519        _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
520    }
521}
522
523#[cfg(test)]
524mod tests;