Skip to main content

kevy_config/
tiering.rs

1//! `[tiering]` section: the transparent-tiering RAM budget in its
2//! three forms —
3//! `"auto"` (0.70 × the detected memory bound), `"70%"` (percent of the
4//! bound), `"4gb"` / plain bytes — plus the optional spill-dir override.
5//!
6//! Resolution to bytes is pure math here ([`TierBudgetSpec::resolve_with`]);
7//! the OS probe itself lives in `kevy-sys::detected_memory_bound` (the
8//! sanctioned boundary) and its result is passed in, keeping this crate
9//! 0-dep. The budget is re-resolved on the shard tick (the CONFIG-SET
10//! reapply precedent) so cgroup limit changes are honored live.
11
12use std::path::PathBuf;
13
14use crate::apply::{schema_err, value_as_string};
15use crate::error::ValueError;
16use crate::parse::{Item, Value};
17use crate::schema::{Config, ConfigError};
18use crate::size::parse_size;
19
20/// The `auto` form's fraction of the detected bound (RFC §7: 0.70).
21const AUTO_PCT: u64 = 70;
22
23/// One tiering budget, as configured (not yet resolved to bytes).
24///
25/// ```
26/// use kevy_config::TierBudgetSpec;
27///
28/// let spec = TierBudgetSpec::parse("50%")?;
29/// assert_eq!(spec.resolve_with(Some(8_000_000_000)), Some(4_000_000_000));
30/// assert_eq!(spec.resolve_with(None), None, "no bound detected: no guess");
31/// # Ok::<(), kevy_config::ValueError>(())
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum TierBudgetSpec {
36    /// `"auto"` — 0.70 × the detected memory bound, re-probed on the
37    /// shard tick.
38    ///
39    /// ```
40    /// use kevy_config::TierBudgetSpec;
41    ///
42    /// assert_eq!(TierBudgetSpec::parse("auto")?, TierBudgetSpec::Auto);
43    /// assert_eq!(TierBudgetSpec::Auto.resolve_with(Some(1000)), Some(700));
44    /// # Ok::<(), kevy_config::ValueError>(())
45    /// ```
46    Auto,
47    /// `"70%"` — that percent of the detected bound (1..=100).
48    ///
49    /// ```
50    /// use kevy_config::TierBudgetSpec;
51    ///
52    /// assert_eq!(TierBudgetSpec::parse("25%")?, TierBudgetSpec::Percent(25));
53    /// assert_eq!(TierBudgetSpec::Percent(25).resolve_with(Some(4000)), Some(1000));
54    /// # Ok::<(), kevy_config::ValueError>(())
55    /// ```
56    Percent(u8),
57    /// `"4gb"` / plain integer — absolute bytes.
58    ///
59    /// ```
60    /// use kevy_config::TierBudgetSpec;
61    ///
62    /// assert_eq!(TierBudgetSpec::parse("4gb")?, TierBudgetSpec::Bytes(4 << 30));
63    /// assert_eq!(TierBudgetSpec::Bytes(4096).resolve_with(None), Some(4096)); // needs no probe
64    /// # Ok::<(), kevy_config::ValueError>(())
65    /// ```
66    Bytes(u64),
67}
68
69impl TierBudgetSpec {
70    /// Parse the wire/TOML/env text form. Accepts `auto`, `N%`
71    /// (1..=100), and any [`parse_size`] literal (`4gb`, `512mb`, bare
72    /// bytes). Garbage errors by name.
73    ///
74    /// ```
75    /// use kevy_config::TierBudgetSpec;
76    /// assert_eq!(TierBudgetSpec::parse("70%"), Ok(TierBudgetSpec::Percent(70)));
77    /// assert!(TierBudgetSpec::parse("0%").is_err());
78    /// ```
79    pub fn parse(s: &str) -> Result<Self, ValueError> {
80        let t = s.trim();
81        if t.eq_ignore_ascii_case("auto") {
82            return Ok(Self::Auto);
83        }
84        if let Some(pct) = t.strip_suffix('%') {
85            let p: u64 = pct.trim().parse().map_err(|_| {
86                ValueError::new(format!("tiering budget percent {s:?} is not a number"))
87            })?;
88            if p == 0 || p > 100 {
89                return Err(ValueError::new(format!(
90                    "tiering budget percent {s:?} must be 1..=100"
91                )));
92            }
93            return Ok(Self::Percent(p as u8));
94        }
95        parse_size(t).map(Self::Bytes).map_err(|e| ValueError::new(format!("tiering budget: {e}")))
96    }
97
98    /// Resolve to bytes given the probed memory bound. `Bytes` ignores
99    /// the probe; `Auto`/`Percent` return `None` when no bound was
100    /// detected (the caller refuses by name — never a silent guess).
101    ///
102    /// ```
103    /// use kevy_config::TierBudgetSpec;
104    ///
105    /// let bound = Some(10 << 30); // e.g. a 10 GiB cgroup limit
106    /// assert_eq!(TierBudgetSpec::Auto.resolve_with(bound), Some((10 << 30) / 100 * 70));
107    /// assert_eq!(TierBudgetSpec::Percent(70).resolve_with(None), None);
108    /// assert_eq!(TierBudgetSpec::Bytes(1 << 20).resolve_with(bound), Some(1 << 20));
109    /// ```
110    pub fn resolve_with(self, detected_bound: Option<u64>) -> Option<u64> {
111        match self {
112            Self::Bytes(b) => Some(b),
113            Self::Auto => detected_bound.map(|d| d / 100 * AUTO_PCT),
114            Self::Percent(p) => detected_bound.map(|d| d / 100 * u64::from(p)),
115        }
116    }
117
118    /// The canonical config-file text form (`auto` / `70%` / bytes).
119    ///
120    /// ```
121    /// use kevy_config::TierBudgetSpec;
122    /// assert_eq!(TierBudgetSpec::Percent(70).to_config_string(), "70%");
123    /// assert_eq!(TierBudgetSpec::Bytes(4096).to_config_string(), "4096");
124    /// ```
125    pub fn to_config_string(self) -> String {
126        match self {
127            Self::Auto => "auto".to_string(),
128            Self::Percent(p) => format!("{p}%"),
129            Self::Bytes(b) => b.to_string(),
130        }
131    }
132}
133
134/// `[tiering]` section. No `budget` key = tiering OFF (today's paths
135/// byte-identical — the A1 gate's precondition).
136///
137/// ```
138/// use kevy_config::{Config, TierBudgetSpec};
139///
140/// assert_eq!(Config::default().tiering.budget, None); // tiering off
141/// let cfg = Config::from_toml_str("[tiering]\nbudget = \"4gb\"\n", None)?;
142/// assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Bytes(4 << 30)));
143/// # Ok::<(), kevy_config::ConfigError>(())
144/// ```
145#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
146#[non_exhaustive]
147pub struct TieringSection {
148    /// The RAM budget. `None` = tiering off.
149    ///
150    /// ```
151    /// use kevy_config::{Config, TierBudgetSpec};
152    ///
153    /// let cfg = Config::from_toml_str("[tiering]\nbudget = 1048576\n", None)?;
154    /// assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Bytes(1 << 20))); // a bare integer is bytes
155    /// # Ok::<(), kevy_config::ConfigError>(())
156    /// ```
157    pub budget: Option<TierBudgetSpec>,
158    /// Cold-tier spill dir override. `None` = `<data_dir>/tier/`.
159    /// Setting it implies tiering on (`budget` defaults to `auto`).
160    ///
161    /// ```
162    /// use kevy_config::{Config, TierBudgetSpec};
163    ///
164    /// let cfg = Config::from_toml_str("[tiering]\nspill_dir = \"/mnt/nvme/kevy-tier\"\n", None)?;
165    /// assert_eq!(cfg.tiering.spill_dir.as_deref().and_then(|p| p.to_str()), Some("/mnt/nvme/kevy-tier"));
166    /// assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Auto), "a spill dir alone turns tiering on");
167    /// # Ok::<(), kevy_config::ConfigError>(())
168    /// ```
169    pub spill_dir: Option<PathBuf>,
170}
171
172impl Config {
173    /// Apply one `[tiering]` item (the `apply_item` dispatch arm).
174    pub(crate) fn apply_tiering(&mut self, item: &Item) -> Result<(), ConfigError> {
175        match item.key.as_str() {
176            "budget" => self.tiering.budget = Some(budget_from_item(item)?),
177            "spill_dir" => {
178                self.tiering.spill_dir = Some(PathBuf::from(value_as_string(item)?));
179                // A spill dir without a budget key means "on, auto".
180                self.tiering.budget.get_or_insert(TierBudgetSpec::Auto);
181            }
182            k => return Err(schema_err(item, format!("unknown [tiering] key: {k}"))),
183        }
184        Ok(())
185    }
186
187    /// The `KEVY_TIER_BUDGET` env arm — all three forms; plain bytes
188    /// stay back-compat with the original plain-bytes-only knob.
189    pub(crate) fn apply_env_tier_budget(&mut self, value: &str) -> Result<(), ConfigError> {
190        self.tiering.budget =
191            Some(TierBudgetSpec::parse(value).map_err(|e| ConfigError::Schema {
192                line: 0,
193                field: "[env] KEVY_TIER_BUDGET".into(),
194                msg: e.to_string(),
195            })?);
196        Ok(())
197    }
198}
199
200/// Budget coercion for `[tiering] budget`: a string in any of the three
201/// forms (`"auto"` / `"70%"` / `"4gb"`), or a bare integer (bytes).
202fn budget_from_item(item: &Item) -> Result<TierBudgetSpec, ConfigError> {
203    match &item.value {
204        Value::Int(n) => u64::try_from(*n)
205            .map(TierBudgetSpec::Bytes)
206            .map_err(|_| schema_err(item, format!("tiering budget {n} must be non-negative"))),
207        Value::Str(s) => TierBudgetSpec::parse(s).map_err(|e| schema_err(item, e.to_string())),
208        other @ (Value::Bool(_) | Value::Arr(_)) => Err(schema_err(
209            item,
210            format!("expected \"auto\" | \"70%\" | \"4gb\" | bytes, got {other:?}"),
211        )),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn parses_all_three_forms() {
221        assert_eq!(TierBudgetSpec::parse("auto").unwrap(), TierBudgetSpec::Auto);
222        assert_eq!(TierBudgetSpec::parse("AUTO").unwrap(), TierBudgetSpec::Auto);
223        assert_eq!(TierBudgetSpec::parse("70%").unwrap(), TierBudgetSpec::Percent(70));
224        assert_eq!(TierBudgetSpec::parse(" 5 %").unwrap(), TierBudgetSpec::Percent(5));
225        assert_eq!(
226            TierBudgetSpec::parse("4gb").unwrap(),
227            TierBudgetSpec::Bytes(4 * 1024 * 1024 * 1024)
228        );
229        assert_eq!(TierBudgetSpec::parse("1048576").unwrap(), TierBudgetSpec::Bytes(1 << 20));
230    }
231
232    #[test]
233    fn garbage_rejected_by_name() {
234        for bad in ["", "yes", "0%", "101%", "x%", "12qb"] {
235            let e = TierBudgetSpec::parse(bad).unwrap_err().to_string();
236            assert!(e.contains("tiering budget"), "{bad:?}: {e}");
237        }
238    }
239
240    #[test]
241    fn resolution_math() {
242        assert_eq!(TierBudgetSpec::Bytes(42).resolve_with(None), Some(42));
243        assert_eq!(TierBudgetSpec::Auto.resolve_with(Some(10_000_000_000)), Some(7_000_000_000));
244        assert_eq!(
245            TierBudgetSpec::Percent(50).resolve_with(Some(8_000_000_000)),
246            Some(4_000_000_000)
247        );
248        assert_eq!(TierBudgetSpec::Auto.resolve_with(None), None);
249        assert_eq!(TierBudgetSpec::Percent(30).resolve_with(None), None);
250    }
251
252    #[test]
253    fn toml_env_cli_parse_all_three_forms() {
254        for (text, want) in [
255            ("[tiering]\nbudget = \"auto\"\n", TierBudgetSpec::Auto),
256            ("[tiering]\nbudget = \"70%\"\n", TierBudgetSpec::Percent(70)),
257            ("[tiering]\nbudget = \"4gb\"\n", TierBudgetSpec::Bytes(4 << 30)),
258            ("[tiering]\nbudget = 1048576\n", TierBudgetSpec::Bytes(1 << 20)),
259        ] {
260            let cfg = Config::from_toml_str(text, None).expect(text);
261            assert_eq!(cfg.tiering.budget, Some(want), "{text}");
262        }
263        // Env: the KEVY_TIER_BUDGET back-compat plain-bytes form + the new ones.
264        for (val, want) in [
265            ("134217728", TierBudgetSpec::Bytes(128 << 20)),
266            ("auto", TierBudgetSpec::Auto),
267            ("35%", TierBudgetSpec::Percent(35)),
268        ] {
269            let mut cfg = Config::default();
270            cfg.merge_env([("KEVY_TIER_BUDGET", val)]).expect(val);
271            assert_eq!(cfg.tiering.budget, Some(want), "{val}");
272        }
273        // CLI override wins.
274        let mut cfg = Config::default();
275        cfg.merge_cli(crate::CliOverrides {
276            tiering_budget: Some(TierBudgetSpec::Percent(25)),
277            ..crate::CliOverrides::default()
278        })
279        .unwrap();
280        assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Percent(25)));
281    }
282
283    #[test]
284    fn toml_garbage_budget_rejected_by_name() {
285        let e = Config::from_toml_str("[tiering]\nbudget = \"lots\"\n", None).unwrap_err();
286        assert!(format!("{e}").contains("tiering budget"), "{e}");
287        let mut cfg = Config::default();
288        let e = cfg.merge_env([("KEVY_TIER_BUDGET", "0%")]).unwrap_err();
289        assert!(format!("{e}").contains("1..=100"), "{e}");
290    }
291
292    #[test]
293    fn spill_dir_alone_implies_auto_budget() {
294        let cfg =
295            Config::from_toml_str("[tiering]\nspill_dir = \"/fast/nvme/tier\"\n", None).unwrap();
296        assert_eq!(cfg.tiering.budget, Some(TierBudgetSpec::Auto));
297        assert_eq!(cfg.tiering.spill_dir.as_deref(), Some(std::path::Path::new("/fast/nvme/tier")));
298    }
299
300    #[test]
301    fn emit_round_trips_the_section_and_its_absence() {
302        // Off (default): the emitted TOML must reparse to off.
303        let off = Config::default();
304        let re = Config::from_toml_str(&off.to_toml_string(), None).unwrap();
305        assert_eq!(re.tiering, TieringSection::default());
306        // On, all fields: byte round trip through the template.
307        let mut on = Config::default();
308        on.tiering.budget = Some(TierBudgetSpec::Percent(60));
309        on.tiering.spill_dir = Some(PathBuf::from("/mnt/tier"));
310        let re = Config::from_toml_str(&on.to_toml_string(), None).unwrap();
311        assert_eq!(re.tiering, on.tiering);
312        for spec in [TierBudgetSpec::Auto, TierBudgetSpec::Bytes(4 << 30)] {
313            let mut c = Config::default();
314            c.tiering.budget = Some(spec);
315            let re = Config::from_toml_str(&c.to_toml_string(), None).unwrap();
316            assert_eq!(re.tiering.budget, Some(spec));
317        }
318    }
319
320    #[test]
321    fn canonical_string_round_trips() {
322        for spec in [
323            TierBudgetSpec::Auto,
324            TierBudgetSpec::Percent(35),
325            TierBudgetSpec::Bytes(4 * 1024 * 1024 * 1024),
326        ] {
327            let text = spec.to_config_string();
328            assert_eq!(TierBudgetSpec::parse(&text).unwrap(), spec, "{text}");
329        }
330    }
331}