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, repositories whose
3//! downloads memory pressure throttled, and how long each 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/// The jobs a week needs for its p95 to be other than its longest wait, the nearest rank `spread` takes.
42const WAITS_ONE_IN_20: u32 = 20;
43
44/// A repository's runs the kernel throttled for memory pressure that make an insight.
45const THROTTLED_MIN_RUNS: u32 = 2;
46
47/// One job the history closed, as insights read it. Times in milliseconds.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Run {
50    /// GitHub's job ID.
51    pub id: u64,
52    /// `owner/name`.
53    pub repository: String,
54    /// The workflow's name, when known.
55    pub workflow: Option<String>,
56    /// The job's name.
57    pub name: String,
58    /// The branch the run is for.
59    pub branch: Option<String>,
60    /// The job's page on GitHub.
61    pub url: Option<String>,
62    /// The workflow run.
63    pub run_id: u64,
64    /// The run's attempt, 1 for the first.
65    pub run_attempt: u32,
66    /// `Completed` or `Lost`.
67    pub state: JobState,
68    /// GitHub's conclusion, for a completed job.
69    pub conclusion: Option<String>,
70    /// When GitHub queued it.
71    pub queued_at: u64,
72    /// When it started.
73    pub started_at: Option<u64>,
74    /// When it finished, or was found lost.
75    pub finished_at: Option<u64>,
76    /// When its runner's container started: before `queued_at`, it found a warm runner.
77    pub runner_started_at: Option<u64>,
78    /// The profile its runner ran in.
79    pub profile: Option<String>,
80    /// Its sampled memory peak.
81    pub memory_peak: Option<u64>,
82    /// What the build cache served it, if its runner had the build cache.
83    pub build_cache: Option<api::BuildCacheCounts>,
84    /// Times the kernel throttled its container's sockets for memory pressure, when counted.
85    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    /// Failed as `GET /v1/stats` counts failures: failed, timed out or lost.
98    fn failed(&self) -> bool {
99        self.state == JobState::Lost || self.failed_itself()
100    }
101
102    /// Failed or timed out: what the job did, not what became of its runner.
103    fn failed_itself(&self) -> bool {
104        self.state == JobState::Completed
105            && matches!(self.conclusion.as_deref(), Some("failure" | "timed_out"))
106    }
107
108    /// Seconds from start to finish.
109    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
129/// A job as insights tell jobs apart: repository, workflow and name.
130type 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
136/// The insights of the `days` before `now` (ms), and the waits of the 14 days before it. `runs` may hold more:
137/// only those finished by `now` count.
138pub 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
181/// The 24 hours before `now` (ms): what finished and failed, the insights that hold at `now` and did not 24 hours
182/// before, and the longest waits.
183pub 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
229/// `repository: workflow / job`, or `repository: job` when the workflow is not known.
230fn 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
261/// The median: the mean of the middle two for an even count.
262fn 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
273/// A job whose successful runs on `main` or `master`, or else its busiest branch, got slower and stayed so.
274fn 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    // The default branch when it has runs enough, so a pull request that adds tests is not a regression; else the
288    // most runs, and of equal counts the first by name.
289    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    // Every recent run slower than the baseline's median: a job that flips between two speeds, such as build cache
307    // hits and misses, moves its median with a few more slow runs in a row without getting slower.
308    if now < threshold || recent.iter().any(|(_, took)| *took <= before) {
309        return None;
310    }
311    // The first slow run after which no two runs in a row were fast.
312    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
348/// A job that failed and then passed on a re-run of the same run.
349fn 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    // Once in the window may be chance: a notice; twice, a warning.
376    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
391/// A job whose last runs' memory peaks near its profile's `memory`: the profile of its last measured run, whose
392/// runs alone count, as a job a repository serves in several profiles peaks apart in each.
393fn 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    // Auto sizing deals with it, and one spike may be chance: a notice. The owner acts on the rest.
441    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
461/// A job's last runs that could write to the build cache, each with this many lookups at least…
462const COLD_RUNS: usize = 5;
463const COLD_MIN_RUNS: usize = 3;
464const COLD_MIN_LOOKUPS: u64 = 10;
465/// …found at most this share of their lookups, in percent: the runs before them stored what they built, so the
466/// next should find most of it.
467const 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
516/// One insight per repository the kernel throttled `THROTTLED_MIN_RUNS` or more of the runs of in `window`.
517fn 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
553/// Each repository's waits in the 7 days before `now` (ms) and the 7 before those.
554fn 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
631/// A duration as a sentence reads it: `45s`, `4m 30s`, `1h 5m`.
632fn 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;