Skip to main content

gunnar_server/
import_room.rs

1//! **An import's memory, against the memory this server may really use** (IMPORT-2, 2026-10-27).
2//!
3//! # What was measured
4//!
5//! torvalds/linux (11 988 094 objects, a 6 602 805 008 B pack) imported through
6//! `Mirror.Add --create` + `Mirror.Sync` into the shipped `gunnar-server` on a
7//! Mini-shaped KVM guest (4 vCPU, 16 GB, no swap, 4 x 10 GB md-raid0), lane
8//! KVM-IMPORT on dwarf, gunnar master a0b0ee50: DONE in 724 s, peak RSS 11.9 GiB,
9//! of which ~6.2 GiB is the mmapped pack (file-backed, reclaimable) and
10//! **5.05 GiB anonymous** at the peak. That box fits it.
11//!
12//! The same import in a 6 GiB cgroup on that VM (`MemoryMax=6G`): the guard read the
13//! VM's 14.6 GB available and never paused, and the kernel OOM-killed gunnar-server
14//! in the index at **anon-rss 6 238 872 kB** — no named error, the job gone with it.
15//!
16//! # Why the box was not the whole question
17//!
18//! The indexer's memory guard (`znippy_plugin_git::memguard`) reads `/proc/meminfo`,
19//! and so did every byte budget derived from it. Inside a cgroup — a container, a
20//! `MemoryMax=` unit — `/proc/meminfo` is the HOST's memory: on oden a
21//! `--memory=16g` container read 500 GB, so the guard never paused and the import
22//! went to 16.08 GB inside a 16 GB limit (import-big, 2026-10-26). A limit the
23//! guard cannot see is a limit nothing keeps the import under.
24//!
25//! # What this module decides
26//!
27//! * [`MemoryLimit`]: the memory this process may use — `MemTotal`, or the
28//!   cgroup v2 `memory.max` when that is smaller — and which of the two it was.
29//! * [`import_room`]: before a fetched pack is indexed, whether indexing it fits
30//!   that limit with [`MARGIN_PERCENT`] to spare. If not, the import is refused BY
31//!   NAME (`[import-memory]`) before the index starts, and the repository it made
32//!   is removed (`mirror_fetch::discard_created`), instead of an index that runs
33//!   the box out of memory.
34//! * [`cgroup_meminfo`]: under a cgroup limit, a `/proc/meminfo`-shaped text of the
35//!   CGROUP's memory (`MemTotal` = `memory.max`, `MemAvailable` = room left plus
36//!   reclaimable file pages), which `mirror_fetch` keeps fresh in a file while the
37//!   import indexes and hands to the store's memory guard, so the guard pauses and
38//!   flushes against the limit that is really there.
39
40use std::path::{Path, PathBuf};
41
42/// Anonymous bytes indexing one object of a fetched pack holds at its peak.
43///
44/// MEASURED (see the module doc): 5172 MiB anonymous at the peak for 11 988 094
45/// objects is 452 B per object, server included. 512 is that figure rounded up,
46/// the side that keeps the box alive.
47pub const IMPORT_BYTES_PER_OBJECT: u64 = 512;
48
49/// What an import holds whatever the pack's size: the server itself (~50 MiB
50/// anonymous when idle, measured), the resolver's workers and their lookahead
51/// (4 x 32 MiB), held delta bases (51 MiB peak measured for linux), row batches.
52pub const IMPORT_FIXED_BYTES: u64 = 512 * 1024 * 1024;
53
54/// The share of the limit an import must leave free: the same 20 % floor the
55/// indexer's memory guard pauses under (`znippy_plugin_git::memguard::DEFAULT_BUDGET_PCT`).
56pub const MARGIN_PERCENT: u64 = 20;
57
58/// Where a [`MemoryLimit`] came from.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum LimitSource {
61    /// `/proc/meminfo` `MemTotal`: no cgroup limit, or a looser one.
62    Box,
63    /// The cgroup v2 `memory.max` of this process, in this directory.
64    Cgroup(PathBuf),
65}
66
67/// The memory this process may use, and why that number.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct MemoryLimit {
70    pub bytes: u64,
71    pub source: LimitSource,
72}
73
74/// One reading of a cgroup v2 directory's memory files.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct CgroupMemory {
77    /// `memory.max`.
78    pub max: u64,
79    /// `memory.current`: everything charged, page cache included.
80    pub current: u64,
81    /// Clean file pages the kernel can drop inside the cgroup: `active_file +
82    /// inactive_file - file_dirty - file_writeback` out of `memory.stat`.
83    pub reclaimable: u64,
84}
85
86impl CgroupMemory {
87    /// What the cgroup still has: the room under `memory.max` plus the clean page
88    /// cache it could drop. Never more than `max`.
89    pub fn available(&self) -> u64 {
90        self.max
91            .saturating_sub(self.current)
92            .saturating_add(self.reclaimable)
93            .min(self.max)
94    }
95}
96
97impl MemoryLimit {
98    /// This process's limit: `/proc/meminfo`, `/proc/self/cgroup`, `/sys/fs/cgroup`.
99    /// `None` when neither the box nor a cgroup can be read.
100    pub fn read() -> Option<MemoryLimit> {
101        Self::read_from(Path::new("/proc/meminfo"), own_cgroup_dir().as_deref())
102    }
103
104    /// The same rule over named files: what a test hands a fake box and a fake cgroup.
105    pub fn read_from(meminfo: &Path, cgroup_dir: Option<&Path>) -> Option<MemoryLimit> {
106        let total = std::fs::read_to_string(meminfo)
107            .ok()
108            .and_then(|t| crate::dashboard::probe::parse_meminfo(&t))
109            .map(|(total, _)| total)
110            .filter(|t| *t > 0);
111        let cgroup = cgroup_dir.and_then(tightest_cgroup);
112        match (total, cgroup) {
113            (Some(total), Some((dir, c))) if c.max < total => Some(MemoryLimit {
114                bytes: c.max,
115                source: LimitSource::Cgroup(dir),
116            }),
117            (None, Some((dir, c))) => Some(MemoryLimit {
118                bytes: c.max,
119                source: LimitSource::Cgroup(dir),
120            }),
121            (Some(total), _) => Some(MemoryLimit {
122                bytes: total,
123                source: LimitSource::Box,
124            }),
125            (None, None) => None,
126        }
127    }
128
129    /// The cgroup directory when the limit is a cgroup's.
130    pub fn cgroup_dir(&self) -> Option<&Path> {
131        match &self.source {
132            LimitSource::Cgroup(dir) => Some(dir),
133            LimitSource::Box => None,
134        }
135    }
136
137    /// What the limit is, in words: `16.0G (MemTotal)`, `6.0G (cgroup memory.max)`.
138    pub fn words(&self) -> String {
139        let source = match self.source {
140            LimitSource::Box => "this box's MemTotal",
141            LimitSource::Cgroup(_) => "this server's cgroup memory.max",
142        };
143        format!("{} ({source})", crate::budget::human(self.bytes))
144    }
145}
146
147/// This process's own cgroup v2 directory, out of `/proc/self/cgroup`'s `0::` line.
148pub fn own_cgroup_dir() -> Option<PathBuf> {
149    let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
150    let rel = own
151        .lines()
152        .find_map(|line| line.strip_prefix("0::"))?
153        .trim()
154        .trim_start_matches('/');
155    Some(Path::new("/sys/fs/cgroup").join(rel))
156}
157
158/// **The tightest limit over `leaf`** (RA5-40): `leaf` and every parent that holds a
159/// `memory.max` (cgroup v2's root holds none, and the walk ends there), the one with the
160/// smallest `memory.max`, with its directory. A slice's `MemoryMax=` or a pod's limit above
161/// the container is a limit as much as the leaf's own. `None`: no limit anywhere.
162pub fn tightest_cgroup(leaf: &Path) -> Option<(PathBuf, CgroupMemory)> {
163    let mut best: Option<(PathBuf, CgroupMemory)> = None;
164    let mut dir = Some(leaf);
165    while let Some(d) = dir {
166        if !d.join("memory.max").exists() {
167            break;
168        }
169        if let Some(c) = cgroup_memory_in(d) {
170            if best.as_ref().is_none_or(|(_, b)| c.max < b.max) {
171                best = Some((d.to_path_buf(), c));
172            }
173        }
174        dir = d.parent();
175    }
176    best
177}
178
179/// `memory.max`, `memory.current` and the reclaimable file pages of `dir`. `None`
180/// when a file is missing or `memory.max` is `max` (no limit).
181pub fn cgroup_memory_in(dir: &Path) -> Option<CgroupMemory> {
182    let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
183    let max: u64 = match max.trim() {
184        "max" => return None,
185        n => n.parse().ok()?,
186    };
187    let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
188        .ok()?
189        .trim()
190        .parse()
191        .ok()?;
192    let stat = std::fs::read_to_string(dir.join("memory.stat")).unwrap_or_default();
193    let field = |name: &str| -> u64 {
194        stat.lines()
195            .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
196            .and_then(|v| v.trim().parse().ok())
197            .unwrap_or(0)
198    };
199    let reclaimable = (field("active_file") + field("inactive_file"))
200        .saturating_sub(field("file_dirty") + field("file_writeback"));
201    Some(CgroupMemory {
202        max,
203        current,
204        reclaimable,
205    })
206}
207
208/// A `/proc/meminfo`-shaped text of a cgroup's memory, for a reader that only
209/// reads `/proc/meminfo` (the indexer's memory guard): `MemTotal` is the cgroup's
210/// `memory.max`, `MemAvailable` its [`CgroupMemory::available`].
211pub fn cgroup_meminfo(c: &CgroupMemory) -> String {
212    format!(
213        "MemTotal:       {} kB\nMemAvailable:   {} kB\n",
214        c.max / 1024,
215        c.available() / 1024
216    )
217}
218
219/// Anonymous memory indexing a fetched pack of `objects` objects is budgeted at.
220pub const fn import_need(objects: u64) -> u64 {
221    IMPORT_FIXED_BYTES + objects * IMPORT_BYTES_PER_OBJECT
222}
223
224/// The most an import may hold under `limit`: the limit less [`MARGIN_PERCENT`].
225pub const fn import_ceiling(limit: u64) -> u64 {
226    limit - limit / 100 * MARGIN_PERCENT
227}
228
229/// **Does indexing a pack of `objects` objects fit `limit`, with the margin?**
230///
231/// `Err` is the refusal, said by name before the index starts: what the pack is,
232/// what indexing it needs, and what this server may use.
233pub fn import_room(path: &str, objects: u64, limit: &MemoryLimit) -> Result<(), String> {
234    let need = import_need(objects);
235    let ceiling = import_ceiling(limit.bytes);
236    if need <= ceiling {
237        return Ok(());
238    }
239    Err(format!(
240        "[import-memory] {path}: the fetched pack has {objects} objects, and indexing it needs \
241         about {} of memory; this server may use {}, and an import keeps {MARGIN_PERCENT} % of \
242         that free, so at most {}. Nothing was imported and the repository was not kept. A \
243         server with more memory imports it.",
244        crate::budget::human(need),
245        limit.words(),
246        crate::budget::human(ceiling),
247    ))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    const GIB: u64 = 1024 * 1024 * 1024;
255    /// torvalds/linux as GitHub served it on 2026-10-27.
256    const LINUX_OBJECTS: u64 = 11_988_094;
257
258    fn fake_box(dir: &Path, total: u64) -> PathBuf {
259        let p = dir.join("meminfo");
260        std::fs::write(
261            &p,
262            format!(
263                "MemTotal: {} kB\nMemFree: 1 kB\nMemAvailable: {} kB\n",
264                total / 1024,
265                total / 1024
266            ),
267        )
268        .unwrap();
269        p
270    }
271
272    fn fake_cgroup(dir: &Path, max: &str, current: u64, stat: &str) -> PathBuf {
273        let cg = dir.join("cg");
274        std::fs::create_dir_all(&cg).unwrap();
275        std::fs::write(cg.join("memory.max"), format!("{max}\n")).unwrap();
276        std::fs::write(cg.join("memory.current"), format!("{current}\n")).unwrap();
277        std::fs::write(cg.join("memory.stat"), stat).unwrap();
278        cg
279    }
280
281    /// ★ A cgroup limit below the box is the limit, and says it is the cgroup's.
282    #[test]
283    fn a_cgroup_limit_below_the_box_is_the_limit() {
284        let d = tempfile::tempdir().unwrap();
285        let meminfo = fake_box(d.path(), 500 * GIB);
286        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
287        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).expect("a limit");
288        assert_eq!(
289            limit.bytes,
290            6 * GIB,
291            "a 6 GiB cgroup on a 500 GiB host may use 6 GiB: {limit:?}"
292        );
293        assert_eq!(limit.source, LimitSource::Cgroup(cg.clone()));
294        assert!(
295            limit.words().contains("cgroup memory.max"),
296            "{}",
297            limit.words()
298        );
299    }
300
301    /// No cgroup limit (`max`), or one above the box: the box is the limit.
302    #[test]
303    fn without_a_cgroup_limit_the_box_is_the_limit() {
304        let d = tempfile::tempdir().unwrap();
305        let meminfo = fake_box(d.path(), 16 * GIB);
306        let cg = fake_cgroup(d.path(), "max", GIB, "");
307        assert_eq!(
308            MemoryLimit::read_from(&meminfo, Some(&cg)),
309            Some(MemoryLimit {
310                bytes: 16 * GIB,
311                source: LimitSource::Box
312            })
313        );
314        let loose = fake_cgroup(d.path(), &(64 * GIB).to_string(), GIB, "");
315        assert_eq!(
316            MemoryLimit::read_from(&meminfo, Some(&loose)).map(|l| l.bytes),
317            Some(16 * GIB)
318        );
319        assert_eq!(
320            MemoryLimit::read_from(&meminfo, None).map(|l| l.source),
321            Some(LimitSource::Box)
322        );
323    }
324
325    /// ★ The model covers what the Mini VM measured, and linux fits the Mini.
326    #[test]
327    fn linux_fits_the_mini_with_the_margin_and_the_model_covers_the_measurement() {
328        // The 5 s sampler's peak on the Mini VM, and the anon-rss the 6 GiB cgroup was
329        // OOM-killed at (still climbing): the budget must cover both.
330        for (measured, what) in [
331            (5172 * 1024 * 1024, "the Mini VM's anonymous peak"),
332            (6_238_872 * 1024, "the anon-rss of the OOM kill"),
333        ] {
334            assert!(
335                import_need(LINUX_OBJECTS) >= measured,
336                "the budget for linux ({}) is below {what} ({})",
337                crate::budget::human(import_need(LINUX_OBJECTS)),
338                crate::budget::human(measured)
339            );
340        }
341        let mini = MemoryLimit {
342            bytes: 16 * GIB,
343            source: LimitSource::Box,
344        };
345        assert_eq!(import_room("alice/linux", LINUX_OBJECTS, &mini), Ok(()));
346    }
347
348    /// ★ The same import inside a 6 GiB cgroup on a big host is refused by name,
349    /// before the index, and the sentence says why in numbers.
350    #[test]
351    fn linux_in_a_6g_cgroup_is_refused_by_name_before_the_index() {
352        let d = tempfile::tempdir().unwrap();
353        let meminfo = fake_box(d.path(), 500 * GIB);
354        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "");
355        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).unwrap();
356        let err =
357            import_room("alice/linux", LINUX_OBJECTS, &limit).expect_err("must not fit 6 GiB");
358        for word in [
359            "[import-memory]",
360            "alice/linux",
361            "11988094 objects",
362            "6.0G",
363            "cgroup memory.max",
364            "4.8G",
365        ] {
366            assert!(err.contains(word), "the refusal must say {word:?}: {err}");
367        }
368        // …and a small repository in the same cgroup is not refused.
369        assert_eq!(import_room("alice/small", 100_000, &limit), Ok(()));
370    }
371
372    /// ★ **RA5-40: a limit on a PARENT cgroup is the limit.** IMPORT-2 read only the leaf's
373    /// `memory.max`; a systemd slice's `MemoryMax=` (or a pod above the container) was missed,
374    /// and the import was admitted against MemTotal, into the OOM kill it exists to prevent. The
375    /// walk goes up while a `memory.max` is there, and the smallest one is the limit.
376    #[test]
377    fn a_parent_cgroups_limit_is_the_limit() {
378        let d = tempfile::tempdir().unwrap();
379        let meminfo = fake_box(d.path(), 500 * GIB);
380        let slice = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
381        let leaf = fake_cgroup(&slice, "max", GIB, "anon 1\n");
382        let limit = MemoryLimit::read_from(&meminfo, Some(&leaf)).expect("a limit");
383        assert_eq!(limit.bytes, 6 * GIB, "the slice's MemoryMax");
384        assert_eq!(
385            limit.cgroup_dir(),
386            Some(slice.as_path()),
387            "named as the slice's"
388        );
389        assert!(import_room("alice/linux", LINUX_OBJECTS, &limit).is_err());
390        // A leaf tighter than its slice is the limit.
391        let tight = fake_cgroup(&slice.join("cg"), &(2 * GIB).to_string(), GIB, "anon 1\n");
392        assert_eq!(
393            MemoryLimit::read_from(&meminfo, Some(&tight))
394                .unwrap()
395                .bytes,
396            2 * GIB
397        );
398    }
399
400    /// The 4 GB twin shape cannot take linux, and says so.
401    #[test]
402    fn a_4g_box_refuses_linux() {
403        let twin = MemoryLimit {
404            bytes: 4 * GIB,
405            source: LimitSource::Box,
406        };
407        let err = import_room("alice/linux", LINUX_OBJECTS, &twin).unwrap_err();
408        assert!(err.contains("MemTotal"), "{err}");
409    }
410
411    /// ★ The cgroup meminfo the guard reads is the cgroup's, not the host's: a
412    /// cgroup near its limit reads near-empty; clean page cache counts as room.
413    #[test]
414    fn the_cgroup_meminfo_is_the_cgroups_memory() {
415        let d = tempfile::tempdir().unwrap();
416        let cg = fake_cgroup(
417            d.path(),
418            &(6 * GIB).to_string(),
419            6 * GIB - 100 * 1024 * 1024,
420            "anon 5000000000\nactive_file 209715200\ninactive_file 104857600\nfile_dirty 104857600\nfile_writeback 0\n",
421        );
422        let c = cgroup_memory_in(&cg).expect("a limited cgroup");
423        // 100 MiB of room + (200 + 100 - 100) MiB clean page cache.
424        assert_eq!(c.available(), 300 * 1024 * 1024);
425        let text = cgroup_meminfo(&c);
426        assert_eq!(
427            crate::dashboard::probe::parse_meminfo(&text),
428            Some((6 * GIB, 300 * 1024 * 1024)),
429            "{text}"
430        );
431        assert_eq!(cgroup_memory_in(&fake_cgroup(d.path(), "max", 1, "")), None);
432    }
433
434    // The indexer's own guard reading that text inside the cgroup is tested where the engine may
435    // be named: `storage/znippy_engine.rs`, `the_indexers_guard_sees_the_cgroup_through_the_text`.
436}