Skip to main content

runnerd_core/api/
budget.rs

1//! What the memory budget means as every client shows it: its line, what is reserved and free of it, what of it no
2//! runner may start in, and the parts a bar draws. Plain derivations of [`Status`]; clients only lay them out.
3
4use super::{AdmissionPolicy, Budget, HostMemory, Status, percent};
5use crate::config::ByteSize;
6
7/// A figure and the words that follow it, such as `8G` and `of 24G reserved in runnerd.slice`, for a client that
8/// sets the figure apart.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Reading {
11    /// Such as `8G`.
12    pub figure: String,
13    /// What follows it, such as `of 24G reserved in runnerd.slice`.
14    pub text: String,
15}
16
17impl Reading {
18    /// The figure and its words as one line.
19    pub fn line(&self) -> String {
20        format!("{} {}", self.figure, self.text)
21    }
22}
23
24impl Budget {
25    /// What is reserved of what, as every client shows it: `8G` `of 24G reserved in runnerd.slice`, or without
26    /// `MemoryMax` `8G` `reserved in runnerd.slice, which has no MemoryMax`; how runners are admitted
27    /// (`Budget::admission_note`) follows the slice.
28    pub fn memory_reading(&self) -> Reading {
29        let of = self
30            .memory_max
31            .map_or_else(String::new, |max| format!("of {} ", ByteSize(max)));
32        let mut text = format!("{of}reserved in {}", self.slice);
33        if let Some(note) = self.admission_note() {
34            text = format!("{text} {note}");
35        }
36        if self.memory_max.is_none() {
37            text.push_str(", which has no MemoryMax");
38        }
39        Reading {
40            figure: ByteSize(self.memory_in_use).to_string(),
41            text,
42        }
43    }
44
45    /// The budget's clauses in the order clients show them: memory (`Budget::memory_reading`), then CPU
46    /// (`CpuBudget::line`) and the host (`Budget::host_line`) when the daemon reports them.
47    pub fn clauses(&self) -> Vec<String> {
48        let mut clauses = vec![self.memory_reading().line()];
49        clauses.extend(self.cpu.map(|cpu| cpu.line()));
50        clauses.extend(self.host_line());
51        clauses
52    }
53
54    /// The host's line as clients show it, such as `host: 31G of 64G available, 1.5% stalled for memory; leaves the
55    /// slice 40G`; when that is less room than `MemoryMax` leaves (`Budget::host_withheld`), `…; leaves the slice 25G
56    /// of its 46G, so 6.5G is free`, and without `MemoryMax`, where the host alone bounds it, `…; leaves the slice
57    /// 25G, so 6.5G is free`; while it stalls, `…; stalling, so no runner starts`. While the slice is held
58    /// (`Budget::held`), nothing is free, so it says no figure of it. With programs outside the slice large enough to
59    /// name, `…; programs outside CI hold 30G, most java 9.3G` (`HostMemory::outside_line`). `None` without a recent
60    /// reading of the host.
61    pub fn host_line(&self) -> Option<String> {
62        let host = self.host.as_ref()?;
63        let mut line = format!("host: {}", host.memory.available_line());
64        if let Some(full) = host.memory.full.filter(|full| full.avg10 > 0) {
65            line = format!("{line}, {} stalled for memory", percent(full.avg10));
66        }
67        let free = match host.spare {
68            0 => "nothing".to_owned(),
69            spare => ByteSize(spare).to_string(),
70        };
71        let limit = ByteSize(host.limit);
72        line = if host.stalled {
73            format!("{line}; stalling, so no runner starts")
74        } else if self.closed() {
75            format!("{line}; leaves the slice {limit}")
76        } else {
77            match self.memory_max {
78                Some(max) if self.host_withheld().is_some() => format!(
79                    "{line}; leaves the slice {limit} of its {}, so {free} is free",
80                    ByteSize(max)
81                ),
82                Some(_) => format!("{line}; leaves the slice {limit}"),
83                None => format!("{line}; leaves the slice {limit}, so {free} is free"),
84            }
85        };
86        if !host.memory.holders.is_empty() {
87            line = format!("{line}; {}", host.memory.outside_line());
88        }
89        Some(line)
90    }
91
92    /// Whether measured admission holds the slice at its high-water mark (`Budget::held`), so no runner starts and
93    /// nothing is free.
94    fn closed(&self) -> bool {
95        self.held && self.admission == AdmissionPolicy::Measured
96    }
97
98    /// What the host's memory holds back of the room `MemoryMax` leaves beside what runs, in bytes: that room less
99    /// what the host has to spare (`HostBudget::spare`), when the host leaves less. `None` while it leaves as much,
100    /// while it stalls (its line says no runner starts), without a recent reading of it, and without `MemoryMax`.
101    pub fn host_withheld(&self) -> Option<u64> {
102        let host = self.host.as_ref().filter(|host| !host.stalled)?;
103        let remaining = self.remaining()?;
104        (host.spare < remaining).then(|| remaining - host.spare)
105    }
106
107    /// The budget as one line, its clauses joined by `; `.
108    pub fn line(&self) -> String {
109        self.clauses().join("; ")
110    }
111
112    /// What `MemoryMax` leaves beside every running container's reservation, in bytes: what a new runner must fit
113    /// in. `None` without `MemoryMax`.
114    pub fn remaining(&self) -> Option<u64> {
115        self.memory_max
116            .map(|max| max.saturating_sub(self.memory_in_use))
117    }
118}
119
120impl HostMemory {
121    /// What the host has available of its memory, as the budget's host clause and doctor say it: `31G of 64G
122    /// available`.
123    pub fn available_line(&self) -> String {
124        format!(
125            "{} of {} available",
126            ByteSize(self.available),
127            ByteSize(self.total)
128        )
129    }
130}
131
132impl Status {
133    /// The budget as a bar draws it: a block per slot as large as its cap (under measured admission, a block per
134    /// runner as large as what it reserves), the mirror, seed containers and shells, then what is free and what of
135    /// the room no runner may start in; and the whole the blocks are scaled against.
136    pub fn budget_parts(&self) -> BudgetParts {
137        let budget = &self.budget;
138        let slots: Vec<SlotPart> = self
139            .profiles
140            .iter()
141            .enumerate()
142            .flat_map(|(profile, status)| {
143                status.slots.iter().filter_map(move |slot| {
144                    let memory = match budget.admission {
145                        AdmissionPolicy::Caps => match &slot.runner {
146                            Some(runner) if runner.reserved > 0 => runner.reserved,
147                            _ => status.memory,
148                        },
149                        AdmissionPolicy::Measured => slot.runner.as_ref()?.reserved,
150                    };
151                    Some(SlotPart {
152                        profile,
153                        slot: slot.slot,
154                        memory,
155                        reserved: slot.runner.is_some(),
156                    })
157                })
158            })
159            .collect();
160        let mirror = (budget.mirror_memory > 0).then_some(HeldPart {
161            kind: HeldKind::Mirror,
162            memory: budget.mirror_memory,
163        });
164        let seeds = budget.seed_memory.iter().map(|seed| HeldPart {
165            kind: HeldKind::Seed {
166                profile: seed.profile.clone(),
167            },
168            memory: seed.memory,
169        });
170        let shells = budget.shell_memory.iter().map(|shell| HeldPart {
171            kind: HeldKind::Shell { job: shell.job },
172            memory: shell.memory,
173        });
174        let held: Vec<HeldPart> = mirror.into_iter().chain(seeds).chain(shells).collect();
175        let drawn: u64 = slots
176            .iter()
177            .map(|part| part.memory)
178            .chain(held.iter().map(|part| part.memory))
179            .sum();
180        let whole = budget.memory_max.unwrap_or(drawn).max(drawn);
181        let unclaimed: u64 = slots
182            .iter()
183            .filter(|part| !part.reserved)
184            .map(|part| part.memory)
185            .sum();
186        let (free, closed) = budget.room_parts(drawn, unclaimed);
187        BudgetParts {
188            slots,
189            held,
190            free,
191            closed,
192            whole,
193        }
194    }
195}
196
197impl Budget {
198    /// The bar's room past `drawn` bytes of blocks, of which `unclaimed` are idle slots' caps: what is free, and what
199    /// of it no runner may start in.
200    fn room_parts(&self, drawn: u64, unclaimed: u64) -> (Option<FreePart>, Option<ClosedPart>) {
201        let Some(max) = self.memory_max else {
202            return (None, None);
203        };
204        let room = max.saturating_sub(drawn);
205        // While no runner starts, all the room is closed.
206        let shut = if self.closed() {
207            Some((
208                ClosedKind::Held,
209                "closed: the slice is past 90% of its MemoryMax, so no runner starts",
210            ))
211        } else if self.host.as_ref().is_some_and(|host| host.stalled) {
212            Some((
213                ClosedKind::Stalled,
214                "closed: the host's programs stall waiting for memory, so no runner starts",
215            ))
216        } else {
217            None
218        };
219        if let Some((kind, text)) = shut {
220            let closed = (room > 0).then(|| ClosedPart {
221                memory: room,
222                kind,
223                reading: Reading {
224                    figure: ByteSize(room).to_string(),
225                    text: text.to_owned(),
226                },
227            });
228            return (None, closed);
229        }
230        let withheld = self.host_withheld();
231        // The host's part ends the bar, within the free room: under caps, idle slots' blocks stay drawn though the
232        // host may hold their room too.
233        let host = withheld
234            .map(|withheld| withheld.min(room))
235            .filter(|&memory| memory > 0);
236        let (host_limit, spare) = self
237            .host
238            .as_ref()
239            .map_or((0, 0), |host| (host.limit, host.spare));
240        let memory = room - host.unwrap_or(0);
241        let text = match (unclaimed, withheld) {
242            (0, None) => format!("of {} free", ByteSize(max)),
243            (_, None) => format!(
244                "of {} free past every slot's cap; {} is free beside what runs",
245                ByteSize(max),
246                ByteSize(self.remaining().unwrap_or(0))
247            ),
248            (0, Some(_)) => format!(
249                "free; the host leaves the slice {} of its {}",
250                ByteSize(host_limit),
251                ByteSize(max)
252            ),
253            (_, Some(_)) => format!(
254                "free past every slot's cap; {} is free beside what runs, as the host leaves the slice {} of its {}",
255                ByteSize(spare),
256                ByteSize(host_limit),
257                ByteSize(max)
258            ),
259        };
260        let free = (memory > 0).then(|| FreePart {
261            memory,
262            reading: Reading {
263                figure: ByteSize(memory).to_string(),
264                text,
265            },
266        });
267        let host = host.map(|memory| ClosedPart {
268            memory,
269            kind: ClosedKind::Host,
270            reading: Reading {
271                figure: ByteSize(memory).to_string(),
272                text: format!(
273                    "{} for its other programs and page cache, which leave the slice {}",
274                    ClosedKind::Host.name(),
275                    ByteSize(host_limit)
276                ),
277            },
278        });
279        (free, host)
280    }
281}
282
283/// The budget as a bar draws it, from `Status::budget_parts`.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct BudgetParts {
286    /// A block per slot, in config order: every slot under caps, only those with a runner under measured admission.
287    pub slots: Vec<SlotPart>,
288    /// The mirror, each seed container, then each shell open in a record.
289    pub held: Vec<HeldPart>,
290    /// What `MemoryMax` leaves past every block, less what is closed; `None` without `MemoryMax`, without room past
291    /// the blocks and the closed part, and while no runner starts: the slice is held (`Budget::held`) or the host
292    /// stalls.
293    pub free: Option<FreePart>,
294    /// What of the room past every block no runner may start in, drawn after what is free: all of it while the slice
295    /// is held or the host stalls, else what the host's memory holds back (`Budget::host_withheld`); `None` while neither closes any.
296    pub closed: Option<ClosedPart>,
297    /// What the blocks are scaled against: `MemoryMax`, or the blocks' sum when larger or without it.
298    pub whole: u64,
299}
300
301/// A slot's block of the budget.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct SlotPart {
304    /// Its profile's index in `Status::profiles`.
305    pub profile: usize,
306    /// The slot, 1-based.
307    pub slot: usize,
308    /// Its size in bytes: what its runner reserves, or a free slot's cap.
309    pub memory: u64,
310    /// Whether a runner reserves it; a free slot's block under caps is room its next runner takes, still free.
311    pub reserved: bool,
312}
313
314/// Memory the budget holds for something other than a slot.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct HeldPart {
317    /// What holds it.
318    pub kind: HeldKind,
319    /// Its cap in bytes.
320    pub memory: u64,
321}
322
323impl HeldPart {
324    /// Its name under its block: `mirror`, `node seed` or `job 42 shell`.
325    pub fn name(&self) -> String {
326        match &self.kind {
327            HeldKind::Mirror => "mirror".to_owned(),
328            HeldKind::Seed { profile } => format!("{profile} seed"),
329            HeldKind::Shell { job } => format!("job {job} shell"),
330        }
331    }
332
333    /// What holds it, after its size: `held by the mirror`, `held by node's seed container` or `held by the shell
334    /// in job 42's record`.
335    pub fn holder(&self) -> String {
336        match &self.kind {
337            HeldKind::Mirror => "held by the mirror".to_owned(),
338            HeldKind::Seed { profile } => format!("held by {profile}'s seed container"),
339            HeldKind::Shell { job } => format!("held by the shell in job {job}'s record"),
340        }
341    }
342}
343
344/// What holds a part of the budget beside the slots.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum HeldKind {
347    /// The registry mirror.
348    Mirror,
349    /// A profile's seed container, while it reseeds.
350    Seed {
351        /// The profile it reseeds.
352        profile: String,
353    },
354    /// A shell open in a job's record.
355    Shell {
356        /// The job.
357        job: u64,
358    },
359}
360
361/// What the bar leaves free past every block, less what the host holds back.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct FreePart {
364    /// In bytes.
365    pub memory: u64,
366    /// Its figure and what it means, such as `8G` `of 24G free`, or while the host binds `6.52G` `free; the host
367    /// leaves the slice 25.27G of its 46G`.
368    pub reading: Reading,
369}
370
371/// Room past the bar's blocks that no runner may start in.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct ClosedPart {
374    /// In bytes.
375    pub memory: u64,
376    /// What closes it.
377    pub kind: ClosedKind,
378    /// Its figure and what it means, such as `20.73G` `held by the host for its other programs and page cache,
379    /// which leave the slice 25.27G`.
380    pub reading: Reading,
381}
382
383/// What closes room past the bar's blocks.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum ClosedKind {
386    /// The host's other programs and its page cache need it.
387    Host,
388    /// The slice is past 90% of its `MemoryMax` under measured admission (`Budget::held`).
389    Held,
390    /// The host's programs stall waiting for memory (`HostBudget::stalled`).
391    Stalled,
392}
393
394impl ClosedKind {
395    /// Its name under its block or in a key: `held by the host`, `closed past 90%` or `closed while the host
396    /// stalls`.
397    pub fn name(self) -> &'static str {
398        match self {
399            Self::Host => "held by the host",
400            Self::Held => "closed past 90%",
401            Self::Stalled => "closed while the host stalls",
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests;