Skip to main content

lean_ctx/core/
index_admission.rs

1//! Admission control for heavy index builds (#685).
2//!
3//! The parallel BM25/graph builds fan the whole corpus across a rayon pool.
4//! On very large corpora (the #685 report: 1M+ files across multiple roots)
5//! the transient build state grows far past `max_ram_percent` before the
6//! memory guardian's 3 s poll can react — the kernel OOM killer fired at
7//! 75 GB RSS. Admission control closes that gap *before* the allocation
8//! happens: estimate the build's peak memory from the corpus size and only
9//! admit the parallel fast path when the estimate fits the remaining headroom
10//! below the guardian's Hard threshold. Oversized corpora degrade to the
11//! sequential build, which carries fine-grained per-file pressure breaks.
12//!
13//! This is a heuristic gate, not an allocator: factors are deliberately
14//! conservative and the in-build batching (see `bm25_index::build`,
15//! `graph_index::process_scan_targets`) remains the second line of defense.
16
17use std::path::Path;
18
19/// Peak-memory expansion factor over raw corpus bytes, per build kind.
20///
21/// BM25 holds chunk contents, lowercased token vectors and inverted postings
22/// simultaneously during the merge; the graph scan retains full file contents
23/// for edge-building plus symbol tables.
24const BM25_EXPANSION_FACTOR: u64 = 5;
25const GRAPH_EXPANSION_FACTOR: u64 = 2;
26
27/// Files above this size are skipped by both builders, so they must not count
28/// against the corpus estimate. Mirrors `MAX_FILE_SIZE_BYTES` in both scanners.
29const BUILDER_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BuildKind {
33    Bm25,
34    GraphScan,
35}
36
37impl BuildKind {
38    fn expansion_factor(self) -> u64 {
39        match self {
40            Self::Bm25 => BM25_EXPANSION_FACTOR,
41            Self::GraphScan => GRAPH_EXPANSION_FACTOR,
42        }
43    }
44
45    fn label(self) -> &'static str {
46        match self {
47            Self::Bm25 => "bm25",
48            Self::GraphScan => "graph",
49        }
50    }
51}
52
53/// Admission decision for a heavy index build.
54#[derive(Debug, Clone)]
55pub struct Admission {
56    /// `true`: the parallel fast path fits the memory budget.
57    /// `false`: degrade to the sequential build (fine-grained pressure breaks).
58    pub parallel_ok: bool,
59    /// Human-readable denial reason for logs (`None` when admitted).
60    pub reason: Option<String>,
61}
62
63impl Admission {
64    fn admitted() -> Self {
65        Self {
66            parallel_ok: true,
67            reason: None,
68        }
69    }
70}
71
72/// Decide whether a parallel build over `corpus_bytes` of source fits the
73/// current memory headroom.
74///
75/// Budget anchor: the guardian escalates to Hard at 2× the configured RSS
76/// limit (`max_ram_percent`), where background builds are aborted anyway —
77/// so a build whose estimated peak would push RSS past that threshold (1.5× max_ram_percent) is
78/// pointless to start in parallel. Everything below stays on the fast path;
79/// normal repositories (a few hundred MB of source) are never affected.
80#[must_use]
81pub fn admit(kind: BuildKind, corpus_bytes: u64) -> Admission {
82    // The unit-test binary's RSS is thousands of parallel tests, not a server;
83    // on a small macOS runner it refused trivial fixtures. The decision itself
84    // is covered through `admit_with`.
85    if cfg!(test) {
86        return Admission::admitted();
87    }
88    let Some(limit) = super::memory_guard::rss_limit_bytes() else {
89        // No platform memory introspection — nothing to enforce.
90        return Admission::admitted();
91    };
92    let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
93    admit_with(kind, corpus_bytes, rss, limit)
94}
95
96/// Pure decision core, separated for tests.
97fn admit_with(kind: BuildKind, corpus_bytes: u64, rss_bytes: u64, limit_bytes: u64) -> Admission {
98    let estimated = corpus_bytes.saturating_mul(kind.expansion_factor());
99    let hard_threshold = limit_bytes.saturating_mul(3) / 2;
100    let available = hard_threshold.saturating_sub(rss_bytes);
101
102    if estimated <= available {
103        return Admission::admitted();
104    }
105
106    Admission {
107        parallel_ok: false,
108        reason: Some(format!(
109            "{} corpus {:.0} MB × {} ≈ {:.0} MB estimated peak exceeds the {:.0} MB headroom \
110             (RSS {:.0} MB, hard limit {:.0} MB = 1.5× max_ram_percent) — degrading to the \
111             sequential build with memory-pressure breaks",
112            kind.label(),
113            corpus_bytes as f64 / 1_048_576.0,
114            kind.expansion_factor(),
115            estimated as f64 / 1_048_576.0,
116            available as f64 / 1_048_576.0,
117            rss_bytes as f64 / 1_048_576.0,
118            hard_threshold as f64 / 1_048_576.0,
119        )),
120    }
121}
122
123/// Sum the on-disk sizes of `files` (relative to `root`), skipping entries the
124/// builders would skip (missing or above the 2 MB per-file cap). Bails out
125/// early once the running total exceeds `cap` — the caller only needs to know
126/// "fits / does not fit", so a 1M-file corpus never pays a full stat walk when
127/// the first thousands of files already blow the budget.
128#[must_use]
129pub fn corpus_bytes_capped(root: &Path, files: &[String], cap: u64) -> u64 {
130    let mut total: u64 = 0;
131    for rel in files {
132        if let Ok(meta) = std::fs::metadata(root.join(rel)) {
133            let len = meta.len();
134            if len > BUILDER_MAX_FILE_BYTES {
135                continue;
136            }
137            total = total.saturating_add(len);
138            if total > cap {
139                return total;
140            }
141        }
142    }
143    total
144}
145
146/// Convenience: full admission check for a file list — stat-walk with early
147/// bail, then the headroom decision. Logs the denial reason once.
148#[must_use]
149pub fn admit_files(kind: BuildKind, root: &Path, files: &[String]) -> Admission {
150    let Some(limit) = super::memory_guard::rss_limit_bytes() else {
151        return Admission::admitted();
152    };
153    let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
154    let available = (limit.saturating_mul(3) / 2).saturating_sub(rss);
155    // The stat walk can stop as soon as the corpus alone proves the estimate
156    // exceeds the headroom (factor ≥ 1 ⇒ corpus > available/factor suffices).
157    let bail_cap = available / kind.expansion_factor().max(1);
158    let corpus = corpus_bytes_capped(root, files, bail_cap);
159    let admission = admit_with(kind, corpus, rss, limit);
160    if let Some(ref reason) = admission.reason {
161        tracing::warn!("[index_admission] {reason}");
162    }
163    admission
164}
165
166#[cfg(test)]
167pub mod tests {
168    use super::*;
169
170    const MB: u64 = 1_048_576;
171
172    #[test]
173    fn small_corpus_is_admitted() {
174        // 50 MB source × 5 = 250 MB estimate, 4 GB headroom → parallel.
175        let a = admit_with(BuildKind::Bm25, 50 * MB, 500 * MB, 2_048 * MB);
176        assert!(a.parallel_ok);
177        assert!(a.reason.is_none());
178    }
179
180    #[test]
181    fn oversized_corpus_degrades_to_sequential() {
182        // 8 GB source × 5 = 40 GB estimate vs (2×4.8 GB − 1 GB) headroom → deny.
183        let a = admit_with(BuildKind::Bm25, 8 * 1024 * MB, 1024 * MB, 4_810 * MB);
184        assert!(!a.parallel_ok);
185        let reason = a.reason.expect("denial carries a reason");
186        assert!(reason.contains("sequential build"), "reason: {reason}");
187    }
188
189    #[test]
190    fn graph_factor_is_smaller_than_bm25() {
191        // Same corpus/headroom: BM25 (×5) denied, graph (×2) admitted.
192        let corpus = 3 * 1024 * MB;
193        let rss = 1024 * MB;
194        let limit = 4_810 * MB;
195        assert!(!admit_with(BuildKind::Bm25, corpus, rss, limit).parallel_ok);
196        assert!(admit_with(BuildKind::GraphScan, corpus, rss, limit).parallel_ok);
197    }
198
199    #[test]
200    fn high_rss_shrinks_headroom() {
201        // Identical corpus: fits with low RSS, denied when RSS already near hard cap.
202        let corpus = 500 * MB;
203        let limit = 2_048 * MB;
204        assert!(admit_with(BuildKind::Bm25, corpus, 100 * MB, limit).parallel_ok);
205        assert!(!admit_with(BuildKind::Bm25, corpus, 3_900 * MB, limit).parallel_ok);
206    }
207
208    #[test]
209    fn corpus_walk_skips_oversized_and_missing_files() {
210        let tmp = tempfile::tempdir().unwrap();
211        std::fs::write(tmp.path().join("small.rs"), vec![b'x'; 1000]).unwrap();
212        std::fs::write(
213            tmp.path().join("big.bin"),
214            vec![b'x'; (BUILDER_MAX_FILE_BYTES + 1) as usize],
215        )
216        .unwrap();
217        let files = vec![
218            "small.rs".to_string(),
219            "big.bin".to_string(),
220            "missing.rs".to_string(),
221        ];
222        assert_eq!(corpus_bytes_capped(tmp.path(), &files, u64::MAX), 1000);
223    }
224
225    #[test]
226    fn corpus_walk_bails_early_at_cap() {
227        let tmp = tempfile::tempdir().unwrap();
228        for i in 0..10 {
229            std::fs::write(tmp.path().join(format!("f{i}.rs")), vec![b'x'; 1000]).unwrap();
230        }
231        let files: Vec<String> = (0..10).map(|i| format!("f{i}.rs")).collect();
232        // Cap of 2500 → stops after the third file (3000 > 2500), not 10 000.
233        let total = corpus_bytes_capped(tmp.path(), &files, 2_500);
234        assert!(total > 2_500 && total < 10_000, "total: {total}");
235    }
236}