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    let Some(limit) = super::memory_guard::rss_limit_bytes() else {
83        // No platform memory introspection — nothing to enforce.
84        return Admission::admitted();
85    };
86    let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
87    admit_with(kind, corpus_bytes, rss, limit)
88}
89
90/// Pure decision core, separated for tests.
91fn admit_with(kind: BuildKind, corpus_bytes: u64, rss_bytes: u64, limit_bytes: u64) -> Admission {
92    let estimated = corpus_bytes.saturating_mul(kind.expansion_factor());
93    let hard_threshold = limit_bytes.saturating_mul(3) / 2;
94    let available = hard_threshold.saturating_sub(rss_bytes);
95
96    if estimated <= available {
97        return Admission::admitted();
98    }
99
100    Admission {
101        parallel_ok: false,
102        reason: Some(format!(
103            "{} corpus {:.0} MB × {} ≈ {:.0} MB estimated peak exceeds the {:.0} MB headroom \
104             (RSS {:.0} MB, hard limit {:.0} MB = 1.5× max_ram_percent) — degrading to the \
105             sequential build with memory-pressure breaks",
106            kind.label(),
107            corpus_bytes as f64 / 1_048_576.0,
108            kind.expansion_factor(),
109            estimated as f64 / 1_048_576.0,
110            available as f64 / 1_048_576.0,
111            rss_bytes as f64 / 1_048_576.0,
112            hard_threshold as f64 / 1_048_576.0,
113        )),
114    }
115}
116
117/// Sum the on-disk sizes of `files` (relative to `root`), skipping entries the
118/// builders would skip (missing or above the 2 MB per-file cap). Bails out
119/// early once the running total exceeds `cap` — the caller only needs to know
120/// "fits / does not fit", so a 1M-file corpus never pays a full stat walk when
121/// the first thousands of files already blow the budget.
122#[must_use]
123pub fn corpus_bytes_capped(root: &Path, files: &[String], cap: u64) -> u64 {
124    let mut total: u64 = 0;
125    for rel in files {
126        if let Ok(meta) = std::fs::metadata(root.join(rel)) {
127            let len = meta.len();
128            if len > BUILDER_MAX_FILE_BYTES {
129                continue;
130            }
131            total = total.saturating_add(len);
132            if total > cap {
133                return total;
134            }
135        }
136    }
137    total
138}
139
140/// Convenience: full admission check for a file list — stat-walk with early
141/// bail, then the headroom decision. Logs the denial reason once.
142#[must_use]
143pub fn admit_files(kind: BuildKind, root: &Path, files: &[String]) -> Admission {
144    let Some(limit) = super::memory_guard::rss_limit_bytes() else {
145        return Admission::admitted();
146    };
147    let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
148    let available = (limit.saturating_mul(3) / 2).saturating_sub(rss);
149    // The stat walk can stop as soon as the corpus alone proves the estimate
150    // exceeds the headroom (factor ≥ 1 ⇒ corpus > available/factor suffices).
151    let bail_cap = available / kind.expansion_factor().max(1);
152    let corpus = corpus_bytes_capped(root, files, bail_cap);
153    let admission = admit_with(kind, corpus, rss, limit);
154    if let Some(ref reason) = admission.reason {
155        tracing::warn!("[index_admission] {reason}");
156    }
157    admission
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    const MB: u64 = 1_048_576;
165
166    #[test]
167    fn small_corpus_is_admitted() {
168        // 50 MB source × 5 = 250 MB estimate, 4 GB headroom → parallel.
169        let a = admit_with(BuildKind::Bm25, 50 * MB, 500 * MB, 2_048 * MB);
170        assert!(a.parallel_ok);
171        assert!(a.reason.is_none());
172    }
173
174    #[test]
175    fn oversized_corpus_degrades_to_sequential() {
176        // 8 GB source × 5 = 40 GB estimate vs (2×4.8 GB − 1 GB) headroom → deny.
177        let a = admit_with(BuildKind::Bm25, 8 * 1024 * MB, 1024 * MB, 4_810 * MB);
178        assert!(!a.parallel_ok);
179        let reason = a.reason.expect("denial carries a reason");
180        assert!(reason.contains("sequential build"), "reason: {reason}");
181    }
182
183    #[test]
184    fn graph_factor_is_smaller_than_bm25() {
185        // Same corpus/headroom: BM25 (×5) denied, graph (×2) admitted.
186        let corpus = 3 * 1024 * MB;
187        let rss = 1024 * MB;
188        let limit = 4_810 * MB;
189        assert!(!admit_with(BuildKind::Bm25, corpus, rss, limit).parallel_ok);
190        assert!(admit_with(BuildKind::GraphScan, corpus, rss, limit).parallel_ok);
191    }
192
193    #[test]
194    fn high_rss_shrinks_headroom() {
195        // Identical corpus: fits with low RSS, denied when RSS already near hard cap.
196        let corpus = 500 * MB;
197        let limit = 2_048 * MB;
198        assert!(admit_with(BuildKind::Bm25, corpus, 100 * MB, limit).parallel_ok);
199        assert!(!admit_with(BuildKind::Bm25, corpus, 3_900 * MB, limit).parallel_ok);
200    }
201
202    #[test]
203    fn corpus_walk_skips_oversized_and_missing_files() {
204        let tmp = tempfile::tempdir().unwrap();
205        std::fs::write(tmp.path().join("small.rs"), vec![b'x'; 1000]).unwrap();
206        std::fs::write(
207            tmp.path().join("big.bin"),
208            vec![b'x'; (BUILDER_MAX_FILE_BYTES + 1) as usize],
209        )
210        .unwrap();
211        let files = vec![
212            "small.rs".to_string(),
213            "big.bin".to_string(),
214            "missing.rs".to_string(),
215        ];
216        assert_eq!(corpus_bytes_capped(tmp.path(), &files, u64::MAX), 1000);
217    }
218
219    #[test]
220    fn corpus_walk_bails_early_at_cap() {
221        let tmp = tempfile::tempdir().unwrap();
222        for i in 0..10 {
223            std::fs::write(tmp.path().join(format!("f{i}.rs")), vec![b'x'; 1000]).unwrap();
224        }
225        let files: Vec<String> = (0..10).map(|i| format!("f{i}.rs")).collect();
226        // Cap of 2500 → stops after the third file (3000 > 2500), not 10 000.
227        let total = corpus_bytes_capped(tmp.path(), &files, 2_500);
228        assert!(total > 2_500 && total < 10_000, "total: {total}");
229    }
230}