Skip to main content

runnerd_core/api/
sizing.rs

1//! Right-sizing as clients read it: per profile and per job (repository, workflow and job name), the spread of the
2//! job history's sampled memory and CPU peaks, how long jobs waited for a slot, how busy the slots were, and what
3//! to set each profile's `memory` and `slots` to. Plain data; the daemon computes it from the history. Sizes are
4//! bytes, CPU millicores, times seconds since the Unix epoch.
5
6use serde::{Deserialize, Serialize};
7
8use crate::api::AdmissionPolicy;
9use crate::config::ByteSize;
10
11mod shown;
12
13pub use shown::{GRANULE, Need, ProfilePlan, SAMPLE_INTERVAL, need, sampled_note};
14
15/// `GET /v1/sizing` query parameters.
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SizingQuery {
18    /// Only jobs queued at or after this time; default 30 days ago.
19    pub since: Option<u64>,
20}
21
22/// `GET /v1/sizing`: what the job history says each configured profile needs.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Sizing {
25    /// The start of the history read: `since`, or the first runner recorded after it.
26    pub since: u64,
27    /// When it was computed.
28    pub until: u64,
29    /// The percent added to a sampled peak, `[sizing] margin`: peaks are sampled every `sample_interval`
30    /// seconds, so a shorter spike between two samples goes unseen.
31    pub margin: u32,
32    /// Seconds between two samples of a job's container.
33    pub sample_interval: u64,
34    /// The fewest jobs a profile or job needs before its percentiles become advice, `[sizing] min_jobs`.
35    pub min_jobs: u32,
36    /// The slice's `MemoryMax`; `None` when it is unlimited.
37    pub memory_max: Option<u64>,
38    /// `[host] admission`, which the advice budgets `MemoryMax` by.
39    pub admission: AdmissionPolicy,
40    /// Every configured profile, by name.
41    pub profiles: Vec<ProfileSizing>,
42    /// Whether the daemon applies advice itself, `[sizing] auto`.
43    pub auto: bool,
44    /// The changes auto sizing made since `since`, oldest first.
45    pub changes: Vec<SizingChange>,
46    /// Under auto sizing, what it does next and when. `None` while it is off.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub auto_plan: Option<AutoPlan>,
49}
50
51/// What auto sizing will do: its rules, the changes its next look would make as the history and config are now, and
52/// the profiles a recent change holds.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct AutoPlan {
55    /// Seconds between two looks of the daemon's.
56    pub every: u64,
57    /// `[sizing] interval`: the least time between a change to one profile and one that lowers it, in seconds.
58    pub interval: u64,
59    /// `[sizing] raise_interval`: the least time between a change to one profile and one that only raises it, in
60    /// seconds.
61    pub raise_interval: u64,
62    /// `[sizing] max_step`: the most one change moves a profile's `memory`, in percent of it.
63    pub max_step: u32,
64    /// The changes the next look would make, in the order it would make them; `at` is when this was worked out.
65    /// None of them is made while `blocked` says why.
66    pub next: Vec<SizingChange>,
67    /// The profiles a change in the last `interval` holds from some change, the hold that ends first first.
68    pub held: Vec<SizingHold>,
69    /// Why no look changes anything until the owner acts, as when the file on disk differs from the config runnerd
70    /// runs with. `None` when the next look may write `next`.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub blocked: Option<String>,
73    /// The profiles whose jobs the CPU cap holds back and whose `cpus` the next look cannot raise, and why.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub refused: Vec<SizingRefused>,
76}
77
78/// A raise of a profile's `cpus` its jobs ask for that auto sizing cannot make, as when it does not fit the CPU
79/// budget.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct SizingRefused {
82    /// The profile.
83    pub profile: String,
84    /// Which job the cap holds back, and what keeps it from a raise.
85    pub reason: String,
86}
87
88/// A profile auto sizing changes nothing in before `until`, and lowers nothing in before `lower_until`.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct SizingHold {
91    /// The profile.
92    pub profile: String,
93    /// When its last change was made.
94    pub changed: u64,
95    /// When it may be raised again, in seconds since the Unix epoch.
96    pub until: u64,
97    /// When it may be lowered again.
98    pub lower_until: u64,
99}
100
101impl SizingHold {
102    /// When it may be lowered again.
103    pub fn lowered_after(&self) -> u64 {
104        self.lower_until.max(self.until)
105    }
106}
107
108/// A profile's `memory` and `slots` as auto sizing changed them in the config file.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct SizingChange {
111    /// When it was written.
112    pub at: u64,
113    /// The profile.
114    pub profile: String,
115    /// What they were: writing these back undoes it.
116    pub from: Resize,
117    /// What they are now.
118    pub to: Resize,
119    /// Why, as the advice said it then.
120    pub reason: String,
121    /// Whether it may not have been written: recorded before its file was written, and the config no longer said
122    /// whether it was when runnerd looked again. It still holds the interval, and `from` is still what it replaced.
123    pub uncertain: bool,
124}
125
126/// One profile: its limits now, its jobs' peaks and waits, and the advice.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct ProfileSizing {
129    /// The profile.
130    pub name: String,
131    /// Its `memory` now: each container's hard limit.
132    pub memory: u64,
133    /// Its `cpus` now.
134    pub cpus: u32,
135    /// Its `slots` now.
136    pub slots: u32,
137    /// Its jobs' peaks.
138    pub peaks: Peaks,
139    /// Seconds from queued to started.
140    pub wait: Spread,
141    /// Its jobs by whether a warm runner took them, and how long each kind waited.
142    pub starts: Starts,
143    /// The percent of the time since `since` its slots held a runner for a job, from when the job was queued, over
144    /// its `slots` now.
145    pub busy: u32,
146    /// Whether its jobs waited a minute or more for a slot at p95, or its slots were busy half the time: more slots
147    /// would help.
148    pub wants_slots: bool,
149    /// What to set `memory` and `slots` to. Sized for its largest peak, so no job gets less than it used.
150    pub advice: Advice,
151    /// When the advice is to take back a lowering auto sizing made that went too far: what showed it.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub revert: Option<SizingRevert>,
154    /// Each job that ran in it, by repository, workflow and name.
155    pub jobs: Vec<JobSizing>,
156}
157
158/// A lowering of a profile's `memory` by auto sizing that went too far, which the advice takes back (ADR 0070).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct SizingRevert {
161    /// The `memory` it lowered to, in bytes.
162    pub from: u64,
163    /// The `memory` it lowered from, which the advice goes back to.
164    pub to: u64,
165    /// The job that showed it, as `owner/name workflow / job`.
166    pub job: String,
167    /// How.
168    pub cause: RevertCause,
169}
170
171/// What showed a lowering went too far.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case", tag = "kind")]
174pub enum RevertCause {
175    /// A run met the lowered cap.
176    Met,
177    /// The job, which succeeded before the lowering, failed this many times in a row at it, in different workflow
178    /// runs and on more than one branch: a heap sized from the cgroup's limit fails without the kernel's limit.
179    Failed {
180        /// How many.
181        runs: u32,
182    },
183}
184
185/// A profile's jobs by whether their runner was already up when GitHub queued them (ADR 0050).
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187pub struct Starts {
188    /// Jobs that started on a warm runner: one that started before the job was queued.
189    pub warm: u32,
190    /// Their median wait from queued to started, in seconds.
191    pub warm_p50: u64,
192    /// Jobs whose runner started after they were queued.
193    pub cold: u32,
194    /// Their median wait, in seconds.
195    pub cold_p50: u64,
196}
197
198/// The jobs of one name in one workflow of one repository.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct JobSizing {
201    /// `owner/name` on GitHub.
202    pub repository: String,
203    /// The workflow's name, when the history knew it.
204    pub workflow: Option<String>,
205    /// The job's name in the workflow.
206    pub name: String,
207    /// Its runs' peaks.
208    pub peaks: Peaks,
209    /// Seconds from queued to started.
210    pub wait: Spread,
211    /// What its profile's `memory` would be for this job alone, and the `slots` that would fit in the memory the
212    /// profile's slots take now.
213    pub advice: Advice,
214}
215
216/// The peaks of a set of jobs, each the highest of its samples.
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218pub struct Peaks {
219    /// How many jobs: the number of peaks the percentiles are taken over.
220    pub jobs: u64,
221    /// Of those, how many met their container's memory cap: its own limit failed reclaim (for a run recorded
222    /// before that was told apart, a kill or a limit hit in the container, or without events a peak within 5% of it).
223    /// They may need more than they show.
224    pub at_cap: u64,
225    /// How many of their jobs are on their cap: one of their runs met it or was killed for memory, and fewer than 3
226    /// successful runs since were clear of it. No lowering while any is.
227    #[serde(default, skip_serializing_if = "is_zero")]
228    pub on_cap: u64,
229    /// Memory without page cache, in bytes.
230    pub memory: Spread,
231    /// CPU, in millicores.
232    pub cpu: Spread,
233}
234
235#[expect(
236    clippy::trivially_copy_pass_by_ref,
237    reason = "serde's skip_serializing_if passes a reference"
238)]
239fn is_zero(count: &u64) -> bool {
240    *count == 0
241}
242
243/// Percentiles of a set of values, by nearest rank: each is one of the values, and with fewer than 100 values
244/// `p99` is the largest.
245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct Spread {
247    /// The median.
248    pub p50: u64,
249    /// The 95th percentile.
250    pub p95: u64,
251    /// The 99th percentile.
252    pub p99: u64,
253    /// The largest.
254    pub max: u64,
255}
256
257/// What a cap keeps at least over the largest peak, for page cache: 1G. Peaks leave page cache out, and a container
258/// whose memory past them leaves the kernel less than about 500M of page cache to reclaim at its cap reclaims badly,
259/// which throttles its sockets and slows its downloads (ADR 0055).
260pub const PAGE_CACHE_ROOM: u64 = 1 << 30;
261
262/// What to set a profile's `memory` and `slots` to. `memory` is the largest sampled peak plus the margin, or plus
263/// `PAGE_CACHE_ROOM` when that is more, rounded up to 256M: the cap kills a job that goes over it, so it is sized for
264/// the heaviest run, not a percentile.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "kebab-case", tag = "kind")]
267pub enum Advice {
268    /// Fewer than `min_jobs` jobs: too few to advise on.
269    TooFew {
270        /// How many there are.
271        jobs: u64,
272    },
273    /// The cap fits, and lowering it would save less than a tenth or a run met its cap.
274    Keep {
275        /// What the jobs need.
276        memory: u64,
277    },
278    /// The cap is a tenth or more above what the jobs need, and no run met its cap.
279    Lower {
280        /// The new `memory`.
281        memory: u64,
282        /// The new `slots`: under `caps` admission, as many as fit in the memory the slots take now when jobs waited
283        /// for one; else the same.
284        slots: u32,
285    },
286    /// The jobs need more than the cap.
287    Raise {
288        /// The new `memory`.
289        memory: u64,
290        /// The new `slots`: the same, or under `caps` admission as many as the slice's `MemoryMax` still holds.
291        slots: u32,
292    },
293    /// The jobs need more than the cap, and the slice's `MemoryMax` does not hold one slot of what they need (under
294    /// `measured` admission, one runner beside the mirror): the slice has to grow first.
295    SliceTooSmall {
296        /// What the jobs need.
297        memory: u64,
298    },
299}
300
301impl Advice {
302    /// The `memory` and `slots` to write, when they differ from the profile's.
303    pub fn change(&self) -> Option<Resize> {
304        match *self {
305            Advice::Lower { memory, slots } | Advice::Raise { memory, slots } => Some(Resize {
306                memory,
307                slots,
308                cpus: None,
309            }),
310            Advice::TooFew { .. } | Advice::Keep { .. } | Advice::SliceTooSmall { .. } => None,
311        }
312    }
313}
314
315/// A profile's new `memory`, `slots` and, when auto sizing changed them, `cpus`.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317pub struct Resize {
318    /// Bytes.
319    pub memory: u64,
320    /// Slots.
321    pub slots: u32,
322    /// CPUs per slot; `None` leaves them as they are, as advice does and as auto sizing's changes recorded before it
323    /// changed them.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub cpus: Option<u32>,
326}
327
328impl AutoPlan {
329    /// Its rules in words, as clients say them after `runnerd`: `looks every 5 minutes, raises a profile at most
330    /// once every 1 hour and lowers it at most once every 24 hours, moving its memory and CPUs by at most 25% at a
331    /// time`.
332    pub fn rules(&self) -> String {
333        if self.raise_interval == 0 || self.raise_interval >= self.interval {
334            return format!(
335                "looks every {} and changes a profile at most once every {}, moving its memory by at most {}% at a \
336                 time",
337                period(self.every),
338                period(self.interval),
339                self.max_step
340            );
341        }
342        format!(
343            "looks every {}, raises a profile at most once every {} and lowers it at most once every {}, moving its \
344             memory and CPUs by at most {}% at a time",
345            period(self.every),
346            period(self.raise_interval),
347            period(self.interval),
348            self.max_step
349        )
350    }
351}
352
353/// A setting's length in words, as a config's periods read: `5 minutes`, `24 hours`, `90 s`.
354pub fn period(seconds: u64) -> String {
355    let plural = |count: u64, unit: &str| {
356        if count == 1 {
357            format!("{count} {unit}")
358        } else {
359            format!("{count} {unit}s")
360        }
361    };
362    match seconds {
363        0 => "0 s".to_owned(),
364        _ if seconds.is_multiple_of(3600) => plural(seconds / 3600, "hour"),
365        _ if seconds.is_multiple_of(60) => plural(seconds / 60, "minute"),
366        _ => format!("{seconds} s"),
367    }
368}
369
370impl SizingChange {
371    /// What it changed, such as `memory 9G → 6.75G, slots 2`, and `cpus 12 → 15` when it changed them.
372    pub fn line(&self) -> String {
373        let memory = if self.from.memory == self.to.memory {
374            format!("memory {}", ByteSize(self.to.memory))
375        } else {
376            format!(
377                "memory {} → {}",
378                ByteSize(self.from.memory),
379                ByteSize(self.to.memory)
380            )
381        };
382        let slots = if self.from.slots == self.to.slots {
383            format!("slots {}", self.to.slots)
384        } else {
385            format!("slots {} → {}", self.from.slots, self.to.slots)
386        };
387        let cpus = match (self.from.cpus, self.to.cpus) {
388            (Some(from), Some(to)) if from != to => format!(", cpus {from} → {to}"),
389            _ => String::new(),
390        };
391        if self.uncertain {
392            format!("{memory}, {slots}{cpus} (may not have been written)")
393        } else {
394            format!("{memory}, {slots}{cpus}")
395        }
396    }
397}
398
399impl Sizing {
400    /// One line on what to do with a profile and why, as `runnerctl sizing`, the dashboard and `runnerd doctor`
401    /// say it, such as `lower memory to 2.5G from 7G, its largest peak + 15%; 5 slots fit in the same 14G`.
402    /// Under `measured` admission, when jobs wanted more slots, it adds that `slots` is not bought with caps there, or
403    /// with auto sizing on, that it adds them.
404    pub fn advice_line(&self, profile: &ProfileSizing) -> String {
405        let line = self.memory_line(profile);
406        if self.admission == AdmissionPolicy::Measured
407            && profile.wants_slots
408            && !matches!(profile.advice, Advice::TooFew { .. })
409        {
410            let then = if self.auto {
411                "auto sizing adds one at a time while every profile's slots fit the slice at their jobs' peaks"
412            } else {
413                "measured admission reserves predicted peaks, not caps, so more slots may fit: raise slots by hand"
414            };
415            format!(
416                "{line}; jobs waited {}s for a slot (p95) and slots were busy {}%: {then}",
417                profile.wait.p95, profile.busy
418            )
419        } else {
420            line
421        }
422    }
423
424    /// `advice_line` without what it says about slots under measured admission.
425    pub(crate) fn memory_line(&self, profile: &ProfileSizing) -> String {
426        let size = |bytes: u64| ByteSize(bytes).to_string();
427        let peaks = &profile.peaks;
428        let need = self.need_reason(peaks);
429        let at_cap = format!(
430            "{} of {} jobs ran out of memory at their cap",
431            peaks.at_cap, peaks.jobs
432        );
433        match profile.advice {
434            Advice::TooFew { jobs } => format!(
435                "{jobs} jobs, too few to advise on (it takes {}); largest peak {} of a {} cap",
436                self.min_jobs,
437                size(peaks.memory.max),
438                size(profile.memory)
439            ),
440            Advice::Keep { .. } if peaks.on_cap > 0 => format!(
441                "keep {}: {} of its jobs met their cap or were killed for memory, and fewer than 3 runs since ran \
442                 clear of it",
443                size(profile.memory),
444                peaks.on_cap
445            ),
446            Advice::Keep { memory } if memory > profile.memory => format!(
447                "keep {}: its recent runs need {}, but none is known to have run out of memory at {} since it was set",
448                size(profile.memory),
449                size(memory),
450                size(profile.memory)
451            ),
452            Advice::Keep { memory } => {
453                format!("keep {}: {need} is {}", size(profile.memory), size(memory))
454            }
455            Advice::Lower { memory, slots } => {
456                let share = profile.memory * u64::from(profile.slots);
457                if self.admission == AdmissionPolicy::Measured {
458                    return format!(
459                        "lower memory to {} from {}, {need}",
460                        size(memory),
461                        size(profile.memory)
462                    );
463                }
464                let then = if slots > profile.slots {
465                    format!("{slots} slots fit in the same {}", size(share))
466                } else {
467                    format!(
468                        "frees {} of the budget",
469                        size(share - memory * u64::from(slots))
470                    )
471                };
472                format!(
473                    "lower memory to {} from {}, {need}; {then}",
474                    size(memory),
475                    size(profile.memory)
476                )
477            }
478            Advice::Raise { memory, .. } if let Some(revert) = &profile.revert => {
479                let how = match revert.cause {
480                    RevertCause::Met => format!("met the {} cap", size(revert.from)),
481                    RevertCause::Failed { runs } => {
482                        format!("failed {runs} times in a row at {}", size(revert.from))
483                    }
484                };
485                format!(
486                    "raise memory back to {}: {} {how} since auto sizing lowered it from {}",
487                    size(memory),
488                    revert.job,
489                    size(revert.to)
490                )
491            }
492            Advice::Raise { memory, slots } => {
493                let why = if peaks.at_cap > 0 {
494                    at_cap
495                } else {
496                    format!("{need} is above the {} cap", size(profile.memory))
497                };
498                let slots = if slots < profile.slots {
499                    format!("; {slots} of its {} slots fit the slice", profile.slots)
500                } else {
501                    String::new()
502                };
503                format!("raise memory to {}: {why}{slots}", size(memory))
504            }
505            Advice::SliceTooSmall { memory } => format!(
506                "needs {memory_needed} ({need}), more than the slice holds for one slot: raise the slice's \
507                 MemoryMax first",
508                memory_needed = size(memory)
509            ),
510        }
511    }
512}