Skip to main content

reflex/query/
open_index.rs

1//! A shared, reusable handle on the open index files.
2//!
3//! Before this module, every query re-opened `content.bin` and `trigrams.bin`
4//! (three to four times per query, once per phase), parsed the whole trigram
5//! directory on each open, and ran `PRAGMA quick_check` over `meta.db`. On a
6//! 29 MB corpus that put a ~50 ms floor under every call, including a zero-hit
7//! search through the resident `rfx mcp` server.
8//!
9//! [`OpenIndex`] holds both memory maps, a path→file-id map, and a rayon pool
10//! sized from `[performance] parallel_threads`. Handles live in a process-wide
11//! registry keyed by the canonical cache directory, so the MCP server, the HTTP
12//! server, and the CLI all share one open per index. A handle is reused while
13//! the on-disk files carry the same fingerprint (device, inode, size, mtime);
14//! an indexer run invalidates it explicitly, and an `rfx index` from another
15//! process is caught by the fingerprint compare on the next lookup.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, OnceLock};
20
21use anyhow::{Context, Result};
22
23use crate::cache::CacheManager;
24use crate::content_store::ContentReader;
25use crate::errors::ReflexError;
26use crate::trigram::TrigramIndex;
27
28/// Identity of one on-disk file, cheap to read (one `stat`).
29#[derive(Debug, Clone, PartialEq, Eq)]
30struct FileStamp {
31    dev: u64,
32    ino: u64,
33    size: u64,
34    mtime: Option<std::time::SystemTime>,
35}
36
37impl FileStamp {
38    fn of(path: &Path) -> std::io::Result<Self> {
39        let md = std::fs::metadata(path)?;
40        #[cfg(unix)]
41        let (dev, ino) = {
42            use std::os::unix::fs::MetadataExt;
43            (md.dev(), md.ino())
44        };
45        #[cfg(not(unix))]
46        let (dev, ino) = (0u64, 0u64);
47        Ok(Self {
48            dev,
49            ino,
50            size: md.len(),
51            mtime: md.modified().ok(),
52        })
53    }
54}
55
56/// Fingerprint of the two binary stores a handle was opened from.
57///
58/// `trigrams` is `None` when `trigrams.bin` is absent and the index was rebuilt
59/// in memory from `content.bin`.
60#[derive(Debug, Clone, PartialEq, Eq)]
61struct Fingerprint {
62    content: FileStamp,
63    trigrams: Option<FileStamp>,
64}
65
66impl Fingerprint {
67    fn current(cache_dir: &Path) -> std::io::Result<Self> {
68        let content = FileStamp::of(&cache_dir.join("content.bin"))?;
69        let trigrams = FileStamp::of(&cache_dir.join("trigrams.bin")).ok();
70        Ok(Self { content, trigrams })
71    }
72}
73
74/// Everything a query needs from the index, opened once.
75pub struct OpenIndex {
76    cache_dir: PathBuf,
77    /// Memory-mapped `content.bin`.
78    pub content: ContentReader,
79    /// Memory-mapped `trigrams.bin` (or an in-memory rebuild when the file is absent).
80    pub trigrams: TrigramIndex,
81    /// `path → file_id`, with any leading `./` stripped, built on first use.
82    /// Only symbol and AST queries need it; a full-text query never pays for it.
83    path_to_id: OnceLock<HashMap<String, u32>>,
84    /// Query-side thread pool, sized from `[performance] parallel_threads`, built
85    /// on first parallel use: a zero-hit query (no candidates to verify) never
86    /// spawns a thread, and neither does a `check_index_status` call.
87    pool: OnceLock<rayon::ThreadPool>,
88    threads: usize,
89    /// `IndexConfig::max_posting_list_entries` at open time (0 = unlimited).
90    posting_cap: usize,
91    fingerprint: Fingerprint,
92    /// One `meta.db` connection for the query path, opened on first use with the
93    /// symbol-cache schema ensured. Symbol queries opened three to four
94    /// connections per call (each running the WAL and foreign-key pragmas) and
95    /// re-ran the schema migration every time.
96    meta: OnceLock<Mutex<rusqlite::Connection>>,
97    /// Every indexed file's fingerprint, loaded on first use by the freshness walk
98    /// (outside git). Dropped with the handle when the index is rewritten.
99    fingerprints: OnceLock<Arc<HashMap<String, crate::cache::FileFingerprint>>>,
100}
101
102impl std::fmt::Debug for OpenIndex {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("OpenIndex")
105            .field("cache_dir", &self.cache_dir)
106            .field("files", &self.content.file_count())
107            .field("threads", &self.threads)
108            .finish()
109    }
110}
111
112/// Upper bound on the automatic thread count for query-time verification.
113///
114/// The indexer caps itself at 8 to limit cache contention while it writes; a
115/// read-only verification pass scales further, and ripgrep's 5x from 16 threads
116/// on the field-test box is the number to match.
117const QUERY_AUTO_THREAD_CAP: usize = 32;
118
119impl OpenIndex {
120    fn open(cache: &CacheManager, fingerprint: Fingerprint) -> Result<Self> {
121        let cache_dir = cache.path().to_path_buf();
122
123        let content = ContentReader::open(cache_dir.join("content.bin"))
124            .map_err(|e| ReflexError::CacheCorrupted(format!("content.bin: {e:#}")))?;
125
126        let trigrams_path = cache_dir.join("trigrams.bin");
127        let trigrams = if trigrams_path.exists() {
128            match TrigramIndex::load(&trigrams_path) {
129                Ok(index) => index,
130                // A format from another Reflex version is not corruption (see
131                // `ReflexError::CacheVersionMismatch`): serve from an in-memory
132                // rebuild, and let the schema-hash check report the index stale
133                // so the caller re-indexes.
134                Err(e) if e.to_string().contains("Unsupported trigrams.bin version") => {
135                    log::warn!("{}; rebuilding trigram index in memory for this process", e);
136                    super::result::rebuild_trigram_index(&content)?
137                }
138                Err(e) => {
139                    return Err(ReflexError::CacheCorrupted(format!("trigrams.bin: {e:#}")).into());
140                }
141            }
142        } else {
143            log::debug!("trigrams.bin not found, rebuilding from content store");
144            super::result::rebuild_trigram_index(&content)?
145        };
146
147        if trigrams.file_count() != content.file_count() {
148            return Err(ReflexError::CacheCorrupted(format!(
149                "trigrams.bin lists {} files but content.bin holds {} (index written by two runs?)",
150                trigrams.file_count(),
151                content.file_count()
152            ))
153            .into());
154        }
155
156        let config = cache.load_index_config().unwrap_or_else(|e| {
157            log::debug!("Using default index config for query pool: {}", e);
158            crate::models::IndexConfig::default()
159        });
160        let threads =
161            crate::models::resolve_thread_count(config.parallel_threads, QUERY_AUTO_THREAD_CAP);
162
163        log::debug!(
164            "Opened index {}: {} files, {} trigrams, {} query threads",
165            cache_dir.display(),
166            content.file_count(),
167            trigrams.trigram_count(),
168            threads
169        );
170
171        Ok(Self {
172            cache_dir,
173            content,
174            trigrams,
175            path_to_id: OnceLock::new(),
176            pool: OnceLock::new(),
177            threads,
178            posting_cap: config.max_posting_list_entries,
179            fingerprint,
180            meta: OnceLock::new(),
181            fingerprints: OnceLock::new(),
182        })
183    }
184
185    /// The fingerprint table, read once per handle.
186    pub fn fingerprints(
187        &self,
188        cache: &CacheManager,
189    ) -> Result<Arc<HashMap<String, crate::cache::FileFingerprint>>> {
190        if let Some(fp) = self.fingerprints.get() {
191            return Ok(Arc::clone(fp));
192        }
193        let loaded = Arc::new(cache.load_fingerprints()?);
194        // A concurrent first caller may have won the race; either table is fine.
195        let _ = self.fingerprints.set(Arc::clone(&loaded));
196        Ok(Arc::clone(self.fingerprints.get().expect("set above")))
197    }
198
199    /// The shared `meta.db` connection, opened on first use.
200    ///
201    /// Lives as long as this handle, which is invalidated whenever the index files
202    /// change on disk or an index run finishes, so it never outlives the index it
203    /// was opened against. WAL readers coexist with a running indexer, and
204    /// `open_meta_db` sets the busy timeout.
205    pub fn meta_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>> {
206        if self.meta.get().is_none() {
207            let conn = crate::cache::open_meta_db(self.cache_dir.join(crate::cache::META_DB))
208                .context("Failed to open meta.db")?;
209            crate::symbol_cache::SymbolCache::ensure_schema(&conn)
210                .context("Failed to initialise the symbol cache schema")?;
211            // A concurrent first caller may have won the race; either connection is fine.
212            let _ = self.meta.set(Mutex::new(conn));
213        }
214        let m = self.meta.get().expect("set above");
215        Ok(m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()))
216    }
217
218    /// Number of files in the index.
219    pub fn file_count(&self) -> usize {
220        self.content.file_count()
221    }
222
223    /// Array file id for a path as stored in the index (a leading `./` is ignored).
224    pub fn file_id_for(&self, path: &str) -> Option<u32> {
225        let normalized = path.strip_prefix("./").unwrap_or(path);
226        let map = self.path_to_id.get_or_init(|| {
227            let mut map = HashMap::with_capacity(self.content.file_count());
228            for id in 0..self.content.file_count() {
229                if let Some(p) = self
230                    .content
231                    .get_file_path(id as u32)
232                    .and_then(|p| p.to_str())
233                {
234                    map.entry(p.strip_prefix("./").unwrap_or(p).to_string())
235                        .or_insert(id as u32);
236                }
237            }
238            map
239        });
240        map.get(normalized).copied()
241    }
242
243    /// Path for an array file id, without a leading `./`.
244    pub fn path_of(&self, file_id: u32) -> Option<&str> {
245        self.content
246            .get_file_path(file_id)
247            .and_then(|p| p.to_str())
248            .map(|p| p.strip_prefix("./").unwrap_or(p))
249    }
250
251    /// The query-side thread pool, built on first use.
252    pub fn pool(&self) -> &rayon::ThreadPool {
253        self.pool.get_or_init(|| {
254            rayon::ThreadPoolBuilder::new()
255                .num_threads(self.threads)
256                .thread_name(|i| format!("rfx-query-{i}"))
257                .build()
258                .unwrap_or_else(|e| {
259                    // A pool that cannot be built (thread limit hit) degrades to
260                    // the global pool rather than failing the query.
261                    log::warn!("Failed to create query thread pool: {}; using default", e);
262                    rayon::ThreadPoolBuilder::new()
263                        .num_threads(1)
264                        .build()
265                        .expect("a single-thread pool")
266                })
267        })
268    }
269
270    /// `max_posting_list_entries` the index was configured with (0 = unlimited).
271    pub fn posting_cap(&self) -> usize {
272        self.posting_cap
273    }
274
275    /// The cache directory this handle was opened from.
276    pub fn cache_dir(&self) -> &Path {
277        &self.cache_dir
278    }
279}
280
281fn registry() -> &'static Mutex<HashMap<PathBuf, Arc<OpenIndex>>> {
282    static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<OpenIndex>>>> = OnceLock::new();
283    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
284}
285
286fn registry_key(cache_dir: &Path) -> PathBuf {
287    cache_dir
288        .canonicalize()
289        .unwrap_or_else(|_| cache_dir.to_path_buf())
290}
291
292/// The shared handle for `cache`, opening it if no current one exists.
293///
294/// Cost on a hit: one lock, two `stat` calls, one `canonicalize`. A handle whose
295/// files have been replaced on disk is dropped and reopened.
296pub fn get_or_open(cache: &CacheManager) -> Result<Arc<OpenIndex>> {
297    let cache_dir = cache.path();
298    let key = registry_key(cache_dir);
299
300    let fingerprint = match Fingerprint::current(cache_dir) {
301        Ok(fp) => fp,
302        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
303            return Err(ReflexError::CacheCorrupted(format!(
304                "content.bin: missing from {}",
305                cache_dir.display()
306            ))
307            .into());
308        }
309        Err(e) => return Err(e).context("Failed to stat index files"),
310    };
311
312    if let Ok(map) = registry().lock()
313        && let Some(existing) = map.get(&key)
314        && existing.fingerprint == fingerprint
315    {
316        return Ok(Arc::clone(existing));
317    }
318
319    let opened = Arc::new(OpenIndex::open(cache, fingerprint)?);
320    if let Ok(mut map) = registry().lock() {
321        map.insert(key, Arc::clone(&opened));
322    }
323    Ok(opened)
324}
325
326/// Drop the registry entry for `cache_dir`, so the next lookup reopens.
327///
328/// Called before and after an index write; also safe to call when nothing is open.
329pub fn invalidate(cache_dir: &Path) {
330    let key = registry_key(cache_dir);
331    if let Ok(mut map) = registry().lock() {
332        map.remove(&key);
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::indexer::Indexer;
340    use crate::models::IndexConfig;
341    use tempfile::TempDir;
342
343    fn indexed_project() -> TempDir {
344        let temp = TempDir::new().unwrap();
345        std::fs::write(temp.path().join("a.rs"), "fn alpha() {}\n").unwrap();
346        std::fs::write(temp.path().join("b.rs"), "fn beta() { alpha() }\n").unwrap();
347        Indexer::new(CacheManager::new(temp.path()), IndexConfig::default())
348            .index(temp.path(), false)
349            .unwrap();
350        temp
351    }
352
353    #[test]
354    fn second_lookup_returns_same_handle() {
355        let temp = indexed_project();
356        let cache = CacheManager::new(temp.path());
357        let first = get_or_open(&cache).unwrap();
358        let second = get_or_open(&cache).unwrap();
359        assert!(Arc::ptr_eq(&first, &second));
360    }
361
362    #[test]
363    fn file_id_lookup_ignores_dot_slash() {
364        let temp = indexed_project();
365        let open = get_or_open(&CacheManager::new(temp.path())).unwrap();
366        let id = open.file_id_for("a.rs").expect("a.rs is indexed");
367        assert_eq!(open.file_id_for("./a.rs"), Some(id));
368        assert_eq!(open.path_of(id), Some("a.rs"));
369        assert_eq!(open.file_id_for("missing.rs"), None);
370    }
371
372    #[test]
373    fn reindex_yields_new_handle() {
374        let temp = indexed_project();
375        let cache = CacheManager::new(temp.path());
376        let first = get_or_open(&cache).unwrap();
377        assert_eq!(first.file_count(), 2);
378
379        std::fs::write(temp.path().join("c.rs"), "fn gamma() {}\n").unwrap();
380        Indexer::new(CacheManager::new(temp.path()), IndexConfig::default())
381            .index(temp.path(), false)
382            .unwrap();
383
384        let second = get_or_open(&cache).unwrap();
385        assert!(!Arc::ptr_eq(&first, &second));
386        assert_eq!(second.file_count(), 3);
387    }
388
389    #[test]
390    fn truncated_content_bin_is_reported_as_corruption() {
391        let temp = indexed_project();
392        let content = temp.path().join(".reflex/content.bin");
393        std::fs::OpenOptions::new()
394            .write(true)
395            .open(&content)
396            .unwrap()
397            .set_len(2)
398            .unwrap();
399
400        let err = get_or_open(&CacheManager::new(temp.path())).unwrap_err();
401        let typed = err
402            .downcast_ref::<ReflexError>()
403            .expect("typed CacheCorrupted");
404        assert!(matches!(typed, ReflexError::CacheCorrupted(_)));
405        assert!(err.to_string().contains("content.bin"), "{err}");
406    }
407}