Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/import_room.rs

//! **An import's memory, against the memory this server may really use** (IMPORT-2, 2026-10-27).
//!
//! # What was measured
//!
//! torvalds/linux (11 988 094 objects, a 6 602 805 008 B pack) imported through
//! `Mirror.Add --create` + `Mirror.Sync` into the shipped `gunnar-server` on a
//! Mini-shaped KVM guest (4 vCPU, 16 GB, no swap, 4 x 10 GB md-raid0), lane
//! KVM-IMPORT on dwarf, gunnar master a0b0ee50: DONE in 724 s, peak RSS 11.9 GiB,
//! of which ~6.2 GiB is the mmapped pack (file-backed, reclaimable) and
//! **5.05 GiB anonymous** at the peak. That box fits it.
//!
//! The same import in a 6 GiB cgroup on that VM (`MemoryMax=6G`): the guard read the
//! VM's 14.6 GB available and never paused, and the kernel OOM-killed gunnar-server
//! in the index at **anon-rss 6 238 872 kB** — no named error, the job gone with it.
//!
//! # Why the box was not the whole question
//!
//! The indexer's memory guard (`znippy_plugin_git::memguard`) reads `/proc/meminfo`,
//! and so did every byte budget derived from it. Inside a cgroup — a container, a
//! `MemoryMax=` unit — `/proc/meminfo` is the HOST's memory: on oden a
//! `--memory=16g` container read 500 GB, so the guard never paused and the import
//! went to 16.08 GB inside a 16 GB limit (import-big, 2026-10-26). A limit the
//! guard cannot see is a limit nothing keeps the import under.
//!
//! # What this module decides
//!
//! * [`MemoryLimit`]: the memory this process may use — `MemTotal`, or the
//!   cgroup v2 `memory.max` when that is smaller — and which of the two it was.
//! * [`import_room`]: before a fetched pack is indexed, whether indexing it fits
//!   that limit with [`MARGIN_PERCENT`] to spare. If not, the import is refused BY
//!   NAME (`[import-memory]`) before the index starts, and the repository it made
//!   is removed (`mirror_fetch::discard_created`), instead of an index that runs
//!   the box out of memory.
//! * [`cgroup_meminfo`]: under a cgroup limit, a `/proc/meminfo`-shaped text of the
//!   CGROUP's memory (`MemTotal` = `memory.max`, `MemAvailable` = room left plus
//!   reclaimable file pages), which `mirror_fetch` keeps fresh in a file while the
//!   import indexes and hands to the store's memory guard, so the guard pauses and
//!   flushes against the limit that is really there.

use std::path::{Path, PathBuf};

/// Anonymous bytes indexing one object of a fetched pack holds at its peak.
///
/// MEASURED (see the module doc): 5172 MiB anonymous at the peak for 11 988 094
/// objects is 452 B per object, server included. 512 is that figure rounded up,
/// the side that keeps the box alive.
pub const IMPORT_BYTES_PER_OBJECT: u64 = 512;

/// What an import holds whatever the pack's size: the server itself (~50 MiB
/// anonymous when idle, measured), the resolver's workers and their lookahead
/// (4 x 32 MiB), held delta bases (51 MiB peak measured for linux), row batches.
pub const IMPORT_FIXED_BYTES: u64 = 512 * 1024 * 1024;

/// The share of the limit an import must leave free: the same 20 % floor the
/// indexer's memory guard pauses under (`znippy_plugin_git::memguard::DEFAULT_BUDGET_PCT`).
pub const MARGIN_PERCENT: u64 = 20;

/// Where a [`MemoryLimit`] came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LimitSource {
    /// `/proc/meminfo` `MemTotal`: no cgroup limit, or a looser one.
    Box,
    /// The cgroup v2 `memory.max` of this process, in this directory.
    Cgroup(PathBuf),
}

/// The memory this process may use, and why that number.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemoryLimit {
    pub bytes: u64,
    pub source: LimitSource,
}

/// One reading of a cgroup v2 directory's memory files.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CgroupMemory {
    /// `memory.max`.
    pub max: u64,
    /// `memory.current`: everything charged, page cache included.
    pub current: u64,
    /// Clean file pages the kernel can drop inside the cgroup: `active_file +
    /// inactive_file - file_dirty - file_writeback` out of `memory.stat`.
    pub reclaimable: u64,
}

impl CgroupMemory {
    /// What the cgroup still has: the room under `memory.max` plus the clean page
    /// cache it could drop. Never more than `max`.
    pub fn available(&self) -> u64 {
        self.max
            .saturating_sub(self.current)
            .saturating_add(self.reclaimable)
            .min(self.max)
    }
}

impl MemoryLimit {
    /// This process's limit: `/proc/meminfo`, `/proc/self/cgroup`, `/sys/fs/cgroup`.
    /// `None` when neither the box nor a cgroup can be read.
    pub fn read() -> Option<MemoryLimit> {
        Self::read_from(Path::new("/proc/meminfo"), own_cgroup_dir().as_deref())
    }

    /// The same rule over named files: what a test hands a fake box and a fake cgroup.
    pub fn read_from(meminfo: &Path, cgroup_dir: Option<&Path>) -> Option<MemoryLimit> {
        let total = std::fs::read_to_string(meminfo)
            .ok()
            .and_then(|t| crate::dashboard::probe::parse_meminfo(&t))
            .map(|(total, _)| total)
            .filter(|t| *t > 0);
        let cgroup = cgroup_dir.and_then(tightest_cgroup);
        match (total, cgroup) {
            (Some(total), Some((dir, c))) if c.max < total => Some(MemoryLimit {
                bytes: c.max,
                source: LimitSource::Cgroup(dir),
            }),
            (None, Some((dir, c))) => Some(MemoryLimit {
                bytes: c.max,
                source: LimitSource::Cgroup(dir),
            }),
            (Some(total), _) => Some(MemoryLimit {
                bytes: total,
                source: LimitSource::Box,
            }),
            (None, None) => None,
        }
    }

    /// The cgroup directory when the limit is a cgroup's.
    pub fn cgroup_dir(&self) -> Option<&Path> {
        match &self.source {
            LimitSource::Cgroup(dir) => Some(dir),
            LimitSource::Box => None,
        }
    }

    /// What the limit is, in words: `16.0G (MemTotal)`, `6.0G (cgroup memory.max)`.
    pub fn words(&self) -> String {
        let source = match self.source {
            LimitSource::Box => "this box's MemTotal",
            LimitSource::Cgroup(_) => "this server's cgroup memory.max",
        };
        format!("{} ({source})", crate::budget::human(self.bytes))
    }
}

/// This process's own cgroup v2 directory, out of `/proc/self/cgroup`'s `0::` line.
pub fn own_cgroup_dir() -> Option<PathBuf> {
    let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let rel = own
        .lines()
        .find_map(|line| line.strip_prefix("0::"))?
        .trim()
        .trim_start_matches('/');
    Some(Path::new("/sys/fs/cgroup").join(rel))
}

/// **The tightest limit over `leaf`** (RA5-40): `leaf` and every parent that holds a
/// `memory.max` (cgroup v2's root holds none, and the walk ends there), the one with the
/// smallest `memory.max`, with its directory. A slice's `MemoryMax=` or a pod's limit above
/// the container is a limit as much as the leaf's own. `None`: no limit anywhere.
pub fn tightest_cgroup(leaf: &Path) -> Option<(PathBuf, CgroupMemory)> {
    let mut best: Option<(PathBuf, CgroupMemory)> = None;
    let mut dir = Some(leaf);
    while let Some(d) = dir {
        if !d.join("memory.max").exists() {
            break;
        }
        if let Some(c) = cgroup_memory_in(d) {
            if best.as_ref().is_none_or(|(_, b)| c.max < b.max) {
                best = Some((d.to_path_buf(), c));
            }
        }
        dir = d.parent();
    }
    best
}

/// `memory.max`, `memory.current` and the reclaimable file pages of `dir`. `None`
/// when a file is missing or `memory.max` is `max` (no limit).
pub fn cgroup_memory_in(dir: &Path) -> Option<CgroupMemory> {
    let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
    let max: u64 = match max.trim() {
        "max" => return None,
        n => n.parse().ok()?,
    };
    let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    let stat = std::fs::read_to_string(dir.join("memory.stat")).unwrap_or_default();
    let field = |name: &str| -> u64 {
        stat.lines()
            .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
            .and_then(|v| v.trim().parse().ok())
            .unwrap_or(0)
    };
    let reclaimable = (field("active_file") + field("inactive_file"))
        .saturating_sub(field("file_dirty") + field("file_writeback"));
    Some(CgroupMemory {
        max,
        current,
        reclaimable,
    })
}

/// A `/proc/meminfo`-shaped text of a cgroup's memory, for a reader that only
/// reads `/proc/meminfo` (the indexer's memory guard): `MemTotal` is the cgroup's
/// `memory.max`, `MemAvailable` its [`CgroupMemory::available`].
pub fn cgroup_meminfo(c: &CgroupMemory) -> String {
    format!(
        "MemTotal:       {} kB\nMemAvailable:   {} kB\n",
        c.max / 1024,
        c.available() / 1024
    )
}

/// Anonymous memory indexing a fetched pack of `objects` objects is budgeted at.
pub const fn import_need(objects: u64) -> u64 {
    IMPORT_FIXED_BYTES + objects * IMPORT_BYTES_PER_OBJECT
}

/// The most an import may hold under `limit`: the limit less [`MARGIN_PERCENT`].
pub const fn import_ceiling(limit: u64) -> u64 {
    limit - limit / 100 * MARGIN_PERCENT
}

/// **Does indexing a pack of `objects` objects fit `limit`, with the margin?**
///
/// `Err` is the refusal, said by name before the index starts: what the pack is,
/// what indexing it needs, and what this server may use.
pub fn import_room(path: &str, objects: u64, limit: &MemoryLimit) -> Result<(), String> {
    let need = import_need(objects);
    let ceiling = import_ceiling(limit.bytes);
    if need <= ceiling {
        return Ok(());
    }
    Err(format!(
        "[import-memory] {path}: the fetched pack has {objects} objects, and indexing it needs \
         about {} of memory; this server may use {}, and an import keeps {MARGIN_PERCENT} % of \
         that free, so at most {}. Nothing was imported and the repository was not kept. A \
         server with more memory imports it.",
        crate::budget::human(need),
        limit.words(),
        crate::budget::human(ceiling),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    const GIB: u64 = 1024 * 1024 * 1024;
    /// torvalds/linux as GitHub served it on 2026-10-27.
    const LINUX_OBJECTS: u64 = 11_988_094;

    fn fake_box(dir: &Path, total: u64) -> PathBuf {
        let p = dir.join("meminfo");
        std::fs::write(
            &p,
            format!(
                "MemTotal: {} kB\nMemFree: 1 kB\nMemAvailable: {} kB\n",
                total / 1024,
                total / 1024
            ),
        )
        .unwrap();
        p
    }

    fn fake_cgroup(dir: &Path, max: &str, current: u64, stat: &str) -> PathBuf {
        let cg = dir.join("cg");
        std::fs::create_dir_all(&cg).unwrap();
        std::fs::write(cg.join("memory.max"), format!("{max}\n")).unwrap();
        std::fs::write(cg.join("memory.current"), format!("{current}\n")).unwrap();
        std::fs::write(cg.join("memory.stat"), stat).unwrap();
        cg
    }

    /// ★ A cgroup limit below the box is the limit, and says it is the cgroup's.
    #[test]
    fn a_cgroup_limit_below_the_box_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).expect("a limit");
        assert_eq!(
            limit.bytes,
            6 * GIB,
            "a 6 GiB cgroup on a 500 GiB host may use 6 GiB: {limit:?}"
        );
        assert_eq!(limit.source, LimitSource::Cgroup(cg.clone()));
        assert!(
            limit.words().contains("cgroup memory.max"),
            "{}",
            limit.words()
        );
    }

    /// No cgroup limit (`max`), or one above the box: the box is the limit.
    #[test]
    fn without_a_cgroup_limit_the_box_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 16 * GIB);
        let cg = fake_cgroup(d.path(), "max", GIB, "");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&cg)),
            Some(MemoryLimit {
                bytes: 16 * GIB,
                source: LimitSource::Box
            })
        );
        let loose = fake_cgroup(d.path(), &(64 * GIB).to_string(), GIB, "");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&loose)).map(|l| l.bytes),
            Some(16 * GIB)
        );
        assert_eq!(
            MemoryLimit::read_from(&meminfo, None).map(|l| l.source),
            Some(LimitSource::Box)
        );
    }

    /// ★ The model covers what the Mini VM measured, and linux fits the Mini.
    #[test]
    fn linux_fits_the_mini_with_the_margin_and_the_model_covers_the_measurement() {
        // The 5 s sampler's peak on the Mini VM, and the anon-rss the 6 GiB cgroup was
        // OOM-killed at (still climbing): the budget must cover both.
        for (measured, what) in [
            (5172 * 1024 * 1024, "the Mini VM's anonymous peak"),
            (6_238_872 * 1024, "the anon-rss of the OOM kill"),
        ] {
            assert!(
                import_need(LINUX_OBJECTS) >= measured,
                "the budget for linux ({}) is below {what} ({})",
                crate::budget::human(import_need(LINUX_OBJECTS)),
                crate::budget::human(measured)
            );
        }
        let mini = MemoryLimit {
            bytes: 16 * GIB,
            source: LimitSource::Box,
        };
        assert_eq!(import_room("alice/linux", LINUX_OBJECTS, &mini), Ok(()));
    }

    /// ★ The same import inside a 6 GiB cgroup on a big host is refused by name,
    /// before the index, and the sentence says why in numbers.
    #[test]
    fn linux_in_a_6g_cgroup_is_refused_by_name_before_the_index() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "");
        let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).unwrap();
        let err =
            import_room("alice/linux", LINUX_OBJECTS, &limit).expect_err("must not fit 6 GiB");
        for word in [
            "[import-memory]",
            "alice/linux",
            "11988094 objects",
            "6.0G",
            "cgroup memory.max",
            "4.8G",
        ] {
            assert!(err.contains(word), "the refusal must say {word:?}: {err}");
        }
        // …and a small repository in the same cgroup is not refused.
        assert_eq!(import_room("alice/small", 100_000, &limit), Ok(()));
    }

    /// ★ **RA5-40: a limit on a PARENT cgroup is the limit.** IMPORT-2 read only the leaf's
    /// `memory.max`; a systemd slice's `MemoryMax=` (or a pod above the container) was missed,
    /// and the import was admitted against MemTotal, into the OOM kill it exists to prevent. The
    /// walk goes up while a `memory.max` is there, and the smallest one is the limit.
    #[test]
    fn a_parent_cgroups_limit_is_the_limit() {
        let d = tempfile::tempdir().unwrap();
        let meminfo = fake_box(d.path(), 500 * GIB);
        let slice = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
        let leaf = fake_cgroup(&slice, "max", GIB, "anon 1\n");
        let limit = MemoryLimit::read_from(&meminfo, Some(&leaf)).expect("a limit");
        assert_eq!(limit.bytes, 6 * GIB, "the slice's MemoryMax");
        assert_eq!(
            limit.cgroup_dir(),
            Some(slice.as_path()),
            "named as the slice's"
        );
        assert!(import_room("alice/linux", LINUX_OBJECTS, &limit).is_err());
        // A leaf tighter than its slice is the limit.
        let tight = fake_cgroup(&slice.join("cg"), &(2 * GIB).to_string(), GIB, "anon 1\n");
        assert_eq!(
            MemoryLimit::read_from(&meminfo, Some(&tight))
                .unwrap()
                .bytes,
            2 * GIB
        );
    }

    /// The 4 GB twin shape cannot take linux, and says so.
    #[test]
    fn a_4g_box_refuses_linux() {
        let twin = MemoryLimit {
            bytes: 4 * GIB,
            source: LimitSource::Box,
        };
        let err = import_room("alice/linux", LINUX_OBJECTS, &twin).unwrap_err();
        assert!(err.contains("MemTotal"), "{err}");
    }

    /// ★ The cgroup meminfo the guard reads is the cgroup's, not the host's: a
    /// cgroup near its limit reads near-empty; clean page cache counts as room.
    #[test]
    fn the_cgroup_meminfo_is_the_cgroups_memory() {
        let d = tempfile::tempdir().unwrap();
        let cg = fake_cgroup(
            d.path(),
            &(6 * GIB).to_string(),
            6 * GIB - 100 * 1024 * 1024,
            "anon 5000000000\nactive_file 209715200\ninactive_file 104857600\nfile_dirty 104857600\nfile_writeback 0\n",
        );
        let c = cgroup_memory_in(&cg).expect("a limited cgroup");
        // 100 MiB of room + (200 + 100 - 100) MiB clean page cache.
        assert_eq!(c.available(), 300 * 1024 * 1024);
        let text = cgroup_meminfo(&c);
        assert_eq!(
            crate::dashboard::probe::parse_meminfo(&text),
            Some((6 * GIB, 300 * 1024 * 1024)),
            "{text}"
        );
        assert_eq!(cgroup_memory_in(&fake_cgroup(d.path(), "max", 1, "")), None);
    }

    // The indexer's own guard reading that text inside the cgroup is tested where the engine may
    // be named: `storage/znippy_engine.rs`, `the_indexers_guard_sees_the_cgroup_through_the_text`.
}