Skip to main content

lean_ctx/core/
storage_maintenance.rs

1//! Daemon-safe storage maintenance.
2//!
3//! Unlike the interactive `lean-ctx cache prune` (which prints per-file output),
4//! these routines are silent (tracing only) so they can run inside the MCP
5//! daemon without corrupting the stdio protocol. They enforce the disk budget
6//! that the field had been silently exceeding (see EPIC 6 / #2364): unbounded
7//! archive FTS growth and accumulated quarantined BM25 indexes.
8
9use std::path::PathBuf;
10
11/// Result of a quiet maintenance pass.
12#[derive(Debug, Default, Clone, Copy)]
13pub struct MaintenanceResult {
14    pub quarantined_removed: u32,
15    pub bytes_freed: u64,
16    pub archive_entries_pruned: u32,
17    pub archive_db_bytes_after: u64,
18}
19
20const QUARANTINED_FILES: &[&str] = &[
21    "bm25_index.json.quarantined",
22    "bm25_index.bin.quarantined",
23    "bm25_index.bin.zst.quarantined",
24];
25
26/// Remove accumulated quarantined BM25 index files. These are dead weight: an
27/// index is only quarantined when it failed a load/size check and was replaced.
28fn prune_quarantined_bm25() -> (u32, u64) {
29    let mut removed = 0u32;
30    let mut freed = 0u64;
31    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
32        return (removed, freed);
33    };
34    let vectors_dir = data_dir.join("vectors");
35    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
36        return (removed, freed);
37    };
38    for entry in entries.flatten() {
39        let dir = entry.path();
40        if !dir.is_dir() {
41            continue;
42        }
43        for q_name in QUARANTINED_FILES {
44            let q: PathBuf = dir.join(q_name);
45            if q.exists() {
46                if let Ok(meta) = std::fs::metadata(&q) {
47                    freed = freed.saturating_add(meta.len());
48                }
49                if std::fs::remove_file(&q).is_ok() {
50                    removed += 1;
51                }
52            }
53        }
54    }
55    (removed, freed)
56}
57
58/// Run a silent maintenance pass: prune quarantined BM25 indexes and enforce
59/// the archive FTS size cap. Safe to call from the MCP daemon.
60pub fn run_quiet() -> MaintenanceResult {
61    let (quarantined_removed, bytes_freed) = prune_quarantined_bm25();
62    // Enforce the archive TTL + on-disk size budget (prunes `.txt`/`.meta.json`
63    // + FTS rows together), then backstop the DB cap. Without this the archive
64    // grew unbounded on disk and exhausted host RAM via the page cache (#417).
65    let archive_entries_pruned = crate::core::archive::cleanup();
66    let archive_db_bytes_after = crate::core::archive_fts::enforce_cap();
67    if quarantined_removed > 0 || archive_entries_pruned > 0 {
68        tracing::info!(
69            "storage maintenance: pruned {quarantined_removed} quarantined BM25 index file(s) \
70             (freed {bytes_freed} bytes) + {archive_entries_pruned} archive entry/entries; \
71             archive DB now {archive_db_bytes_after} bytes"
72        );
73    }
74    MaintenanceResult {
75        quarantined_removed,
76        bytes_freed,
77        archive_entries_pruned,
78        archive_db_bytes_after,
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn prune_removes_quarantined_files() {
88        let _lock = crate::core::data_dir::test_env_lock();
89        let tmp = tempfile::tempdir().unwrap();
90        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
91
92        let idx_dir = tmp.path().join("vectors").join("proj_abc");
93        std::fs::create_dir_all(&idx_dir).unwrap();
94        std::fs::write(idx_dir.join("bm25_index.json.quarantined"), b"dead").unwrap();
95        std::fs::write(idx_dir.join("bm25_index.bin"), b"live").unwrap();
96
97        let (removed, freed) = prune_quarantined_bm25();
98        assert_eq!(removed, 1);
99        assert!(freed >= 4);
100        assert!(!idx_dir.join("bm25_index.json.quarantined").exists());
101        assert!(
102            idx_dir.join("bm25_index.bin").exists(),
103            "live index must be preserved"
104        );
105
106        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
107    }
108}