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