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