Skip to main content

runnerd_core/config/
budget.rs

1//! What a config check says of the memory budget as every client shows it: its verdict, what the admission rule
2//! holds to `MemoryMax` and what that leaves, the blocks a preview draws, and doctor's check. Derived from
3//! [`api::ConfigCheck`] and the [`Config`] it checked; clients only lay them out.
4
5use super::{ByteSize, Config, ConfigError};
6use crate::api::{AdmissionPolicy, Check, CheckStatus, ConfigCheck, Tone};
7
8/// A config check's verdict, from `ConfigCheck::verdict`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Verdict {
11    /// Valid, and what its admission rule holds fits `MemoryMax`.
12    Fits,
13    /// Valid, but the slice has no `MemoryMax`: nothing holds runners to a budget.
14    NoBudget,
15    /// Not valid: what its admission rule holds is more than `MemoryMax`.
16    DoesNotFit,
17    /// Not valid for another reason, given in `ConfigCheck::message`: it does not parse, a setting is wrong, or the
18    /// slice could not be read.
19    Invalid,
20}
21
22impl Verdict {
23    /// As a heading says it: `Fits the budget`, `Valid, with no MemoryMax to fit`, `Does not fit` or `Not valid`.
24    pub fn label(self) -> &'static str {
25        match self {
26            Verdict::Fits => "Fits the budget",
27            Verdict::NoBudget => "Valid, with no MemoryMax to fit",
28            Verdict::DoesNotFit => "Does not fit",
29            Verdict::Invalid => "Not valid",
30        }
31    }
32
33    /// What it means for the reader.
34    pub fn tone(self) -> Tone {
35        match self {
36            Verdict::Fits => Tone::Good,
37            Verdict::NoBudget => Tone::Warn,
38            Verdict::DoesNotFit | Verdict::Invalid => Tone::Bad,
39        }
40    }
41}
42
43/// What a checked config's admission rule holds to `MemoryMax`, from `ConfigCheck::budget`.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct CheckBudget {
46    /// The admission rule it was checked under.
47    pub admission: AdmissionPolicy,
48    /// What the rule must fit in `MemoryMax`, in bytes.
49    pub needed: u64,
50    /// The slice's `MemoryMax`; `None` when unlimited or unknown.
51    pub memory_max: Option<u64>,
52    /// Every slot and the mirror at their caps at once, in bytes, when known.
53    pub memory_caps: Option<u64>,
54}
55
56impl CheckBudget {
57    /// What `needed` is, as a figure's label: `Memory caps` or `Largest runner + mirror`.
58    pub fn needed_label(&self) -> &'static str {
59        match self.admission {
60            AdmissionPolicy::Caps => "Memory caps",
61            AdmissionPolicy::Measured => "Largest runner + mirror",
62        }
63    }
64
65    /// Whether `needed` is more than `MemoryMax`.
66    pub fn over(&self) -> bool {
67        self.memory_max.is_some_and(|max| self.needed > max)
68    }
69
70    /// What `MemoryMax` leaves past `needed`, or how far `needed` goes past it. `None` without `MemoryMax`.
71    pub fn remainder(&self) -> Option<Remainder> {
72        let max = self.memory_max?;
73        Some(if self.over() {
74            Remainder {
75                label: "Over by",
76                memory: self.needed - max,
77            }
78        } else {
79            Remainder {
80                label: "Left",
81                memory: max - self.needed,
82            }
83        })
84    }
85
86    /// Under measured admission, what that means for the slots: `Admission is measured: runners reserve their
87    /// predicted peaks, and slots only bound how many run at once. Every slot at its cap at once would take 40G.`
88    pub fn admission_note(&self) -> Option<String> {
89        if self.admission != AdmissionPolicy::Measured {
90            return None;
91        }
92        let mut note = "Admission is measured: runners reserve their predicted peaks, and slots only bound how many \
93                        run at once."
94            .to_owned();
95        if let Some(caps) = self.memory_caps {
96            note = format!(
97                "{note} Every slot at its cap at once would take {}.",
98                ByteSize(caps)
99            );
100        }
101        Some(note)
102    }
103}
104
105/// What `MemoryMax` leaves past a check's budget, or how far the budget goes past it.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Remainder {
108    /// `Left` or `Over by`.
109    pub label: &'static str,
110    /// In bytes.
111    pub memory: u64,
112}
113
114impl ConfigCheck {
115    /// Its verdict: whether it is valid, and whether its budget fits. With `parsed`, the config it checked, only a
116    /// budget past `MemoryMax` that is the reason it is not valid does not fit; without it, as when the text does not
117    /// parse, a budget the check reports past `MemoryMax`.
118    pub fn verdict(&self, parsed: Option<&Config>) -> Verdict {
119        if self.valid {
120            return if self.memory_max.is_none() {
121                Verdict::NoBudget
122            } else {
123                Verdict::Fits
124            };
125        }
126        let over = match parsed {
127            Some(config) => matches!(
128                config.validate(self.memory_max.map(ByteSize)),
129                Err(ConfigError::OverBudget { .. } | ConfigError::ProfileOverBudget { .. })
130            ),
131            None => self.budget().is_some_and(|budget| budget.over()),
132        };
133        if over {
134            Verdict::DoesNotFit
135        } else {
136            Verdict::Invalid
137        }
138    }
139
140    /// What its admission rule holds to `MemoryMax`; `None` when the check could not say, as when the config does
141    /// not parse or its caps overflow.
142    pub fn budget(&self) -> Option<CheckBudget> {
143        self.memory_needed.map(|needed| CheckBudget {
144            admission: self.admission,
145            needed,
146            memory_max: self.memory_max,
147            memory_caps: self.memory_caps,
148        })
149    }
150
151    /// Doctor's check of the config the daemon runs with: it passes with its message, or with its
152    /// warnings, or fails with why.
153    pub fn doctor_check(&self) -> Check {
154        if !self.valid {
155            return Check {
156                name: "config".to_owned(),
157                status: CheckStatus::Fail,
158                detail: self.message.clone(),
159                fix: Some("change runnerd.toml or the slice's limits".to_owned()),
160            };
161        }
162        let detail = if self.warnings.is_empty() {
163            self.message.clone()
164        } else {
165            format!(
166                "{}, with warnings: {}",
167                self.message,
168                self.warnings.join("; ")
169            )
170        };
171        Check {
172            name: "config".to_owned(),
173            status: CheckStatus::Pass,
174            detail,
175            fix: None,
176        }
177    }
178}
179
180/// A profile's slots or the mirror, as a budget preview draws it, from `Config::budget_blocks`.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct BudgetBlock {
183    /// What it is: `jvm ×2` or `mirror`.
184    pub label: String,
185    /// In bytes.
186    pub memory: u64,
187    /// The profile's index in the config's order; `None` for the mirror.
188    pub profile: Option<usize>,
189}
190
191impl Config {
192    /// What `admission` holds to `MemoryMax`, a block per profile: under caps, every slot of each profile at its
193    /// cap; under measured, one runner of the largest profile. The mirror with either. `None` when the caps
194    /// overflow, as `Config::memory_caps` refuses them.
195    pub fn budget_blocks(&self, admission: AdmissionPolicy) -> Option<Vec<BudgetBlock>> {
196        let profiles = self
197            .profiles
198            .iter()
199            .enumerate()
200            .map(|(index, (name, profile))| (index, name, profile));
201        let mut blocks: Vec<BudgetBlock> = match admission {
202            AdmissionPolicy::Caps => profiles
203                .map(|(index, name, profile)| {
204                    Some(BudgetBlock {
205                        label: format!("{name} ×{}", profile.slots),
206                        memory: profile.memory.0.checked_mul(u64::from(profile.slots))?,
207                        profile: Some(index),
208                    })
209                })
210                .collect::<Option<_>>()?,
211            AdmissionPolicy::Measured => profiles
212                .max_by_key(|(_, _, profile)| profile.memory.0)
213                .map(|(index, name, profile)| BudgetBlock {
214                    label: format!("{name} ×1"),
215                    memory: profile.memory.0,
216                    profile: Some(index),
217                })
218                .into_iter()
219                .collect(),
220        };
221        if self.caches.mirror.enabled {
222            blocks.push(BudgetBlock {
223                label: "mirror".to_owned(),
224                memory: self.mirror_memory(),
225                profile: None,
226            });
227        }
228        let total = blocks
229            .iter()
230            .try_fold(0u64, |total, block| total.checked_add(block.memory));
231        total.map(|_| blocks)
232    }
233
234    /// Validates the parsed config against `memory_max`, the slice's `MemoryMax`: what `Config::check` answers once
235    /// the text parses and the slice is read, and what doctor checks the config the daemon runs with by.
236    pub fn check_against(&self, memory_max: Option<ByteSize>) -> ConfigCheck {
237        let figures = self
238            .validate(memory_max)
239            .and_then(|warnings| Ok((warnings, self.memory_caps()?, self.memory_needed()?)));
240        match figures {
241            Ok((warnings, total, needed)) => {
242                let message = match (self.host.admission, memory_max) {
243                    (super::Admission::Measured, Some(max)) => format!(
244                        "valid under admission = \"measured\": the largest runner with the mirror needs {needed} \
245                         of the {max} MemoryMax; every slot at its cap at once would take {total}"
246                    ),
247                    _ => format!("valid, memory caps sum to {total}"),
248                };
249                ConfigCheck {
250                    valid: true,
251                    message,
252                    warnings: warnings.iter().map(ToString::to_string).collect(),
253                    admission: self.host.admission.policy(),
254                    memory_caps: Some(total.0),
255                    memory_needed: Some(needed.0),
256                    memory_max: memory_max.map(|max| max.0),
257                    revision: None,
258                }
259            }
260            Err(error) => invalid(error.to_string(), Some(self), memory_max),
261        }
262    }
263}
264
265/// A check that is not valid for `message`, with what `config`, when it parsed, says of its budget.
266pub(super) fn invalid(
267    message: String,
268    config: Option<&Config>,
269    max: Option<ByteSize>,
270) -> ConfigCheck {
271    ConfigCheck {
272        valid: false,
273        message,
274        warnings: Vec::new(),
275        admission: config
276            .map(|config| config.host.admission.policy())
277            .unwrap_or_default(),
278        memory_caps: config
279            .and_then(|config| config.memory_caps().ok())
280            .map(|caps| caps.0),
281        memory_needed: config
282            .and_then(|config| config.memory_needed().ok())
283            .map(|need| need.0),
284        memory_max: max.map(|max| max.0),
285        revision: None,
286    }
287}
288
289#[cfg(test)]
290mod tests;