Skip to main content

reflex/
background_indexer.rs

1//! Background symbol indexer for transparent caching
2//!
3//! This module provides background processing to parse symbols from all indexed
4//! files and populate the symbol cache. It runs as a separate process spawned by
5//! `rfx index`, allowing users to continue working while symbols are being indexed.
6
7use anyhow::{Context, Result};
8use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::fs::File;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use crate::cache::CacheManager;
17use crate::content_store::ContentReader;
18use crate::parsers::ParserFactory;
19use crate::symbol_cache::SymbolCache;
20
21/// Lock file name to prevent concurrent indexing
22const LOCK_FILE: &str = "indexing.lock";
23
24/// Maximum age of a lock file before it's considered stale (1 hour)
25const LOCK_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(3600);
26
27/// Status file name for progress tracking
28const STATUS_FILE: &str = "indexing.status";
29
30/// Indexing progress status
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct IndexingStatus {
33    /// Current state of the indexer
34    pub state: IndexerState,
35    /// Total files to process
36    pub total_files: usize,
37    /// Files processed so far
38    pub processed_files: usize,
39    /// Files that had symbols cached
40    pub cached_files: usize,
41    /// Files that were newly parsed
42    pub parsed_files: usize,
43    /// Files that failed to parse
44    pub failed_files: usize,
45    /// Start time (ISO 8601)
46    pub started_at: String,
47    /// Last update time (ISO 8601)
48    pub updated_at: String,
49    /// Completion time (ISO 8601, None if not finished)
50    pub completed_at: Option<String>,
51    /// Error message if failed
52    pub error: Option<String>,
53}
54
55/// Indexer state
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum IndexerState {
59    /// Indexer is currently running
60    Running,
61    /// Indexer completed successfully
62    Completed,
63    /// Indexer failed with error
64    Failed,
65}
66
67/// Check if a lock file is stale based on its modification time
68///
69/// A lock file is considered stale if its mtime is older than `LOCK_MAX_AGE`.
70/// This allows recovery from crashed indexer processes that didn't clean up
71/// their lock file (SIGKILL, OOM, power loss, etc.).
72fn is_lock_stale(lock_path: &Path) -> bool {
73    let metadata = match std::fs::metadata(lock_path) {
74        Ok(m) => m,
75        Err(_) => return false, // Can't read => not stale, let caller handle
76    };
77    let modified = match metadata.modified() {
78        Ok(t) => t,
79        Err(_) => return false,
80    };
81    match modified.elapsed() {
82        Ok(age) => age > LOCK_MAX_AGE,
83        Err(_) => false, // Clock skew — don't remove
84    }
85}
86
87/// Background symbol indexer
88pub struct BackgroundIndexer {
89    workspace_path: PathBuf,
90    cache_path: PathBuf,
91    status: IndexingStatus,
92    batch_size: usize,
93}
94
95impl BackgroundIndexer {
96    /// Create a new background indexer
97    ///
98    /// # Arguments
99    /// * `workspace_path` - Path to the workspace root (e.g., ".")
100    pub fn new(workspace_path: &Path) -> Result<Self> {
101        let now = chrono::Utc::now().to_rfc3339();
102
103        // Create CacheManager to get the cache directory path
104        let cache_mgr = CacheManager::new(workspace_path);
105        let cache_path = cache_mgr.path().to_path_buf();
106
107        Ok(Self {
108            workspace_path: workspace_path.to_path_buf(),
109            cache_path,
110            status: IndexingStatus {
111                state: IndexerState::Running,
112                total_files: 0,
113                processed_files: 0,
114                cached_files: 0,
115                parsed_files: 0,
116                failed_files: 0,
117                started_at: now.clone(),
118                updated_at: now,
119                completed_at: None,
120                error: None,
121            },
122            batch_size: 500, // Batch symbol writes for performance (increased for better throughput)
123        })
124    }
125
126    /// Check if an indexing process is already running
127    ///
128    /// If the lock file exists but is older than `LOCK_MAX_AGE`, it's treated
129    /// as stale (left behind by a crashed process) and removed automatically.
130    pub fn is_running(cache_dir: &Path) -> bool {
131        let lock_path = cache_dir.join(LOCK_FILE);
132        if !lock_path.exists() {
133            return false;
134        }
135        if is_lock_stale(&lock_path) {
136            log::warn!(
137                "Removing stale indexing lock file (older than {:?})",
138                LOCK_MAX_AGE
139            );
140            let _ = std::fs::remove_file(&lock_path);
141            return false;
142        }
143        true
144    }
145
146    /// Get the current indexing status (if available)
147    pub fn get_status(cache_dir: &Path) -> Result<Option<IndexingStatus>> {
148        let status_path = cache_dir.join(STATUS_FILE);
149
150        if !status_path.exists() {
151            return Ok(None);
152        }
153
154        let status_json =
155            std::fs::read_to_string(&status_path).context("Failed to read indexing status")?;
156
157        let status: IndexingStatus =
158            serde_json::from_str(&status_json).context("Failed to parse indexing status")?;
159
160        Ok(Some(status))
161    }
162
163    /// Acquire lock file (returns error if already locked)
164    ///
165    /// If a stale lock file is detected, it is removed before acquiring.
166    /// This provides defense-in-depth alongside the `is_running()` check.
167    fn acquire_lock(&self) -> Result<File> {
168        let lock_path = self.cache_path.join(LOCK_FILE);
169
170        if lock_path.exists() {
171            if is_lock_stale(&lock_path) {
172                log::warn!(
173                    "Removing stale indexing lock file (older than {:?})",
174                    LOCK_MAX_AGE
175                );
176                let _ = std::fs::remove_file(&lock_path);
177            } else {
178                anyhow::bail!("Indexing already in progress (lock file exists)");
179            }
180        }
181
182        let mut lock_file = File::create(&lock_path).context("Failed to create lock file")?;
183
184        let pid = std::process::id();
185        writeln!(lock_file, "{}", pid)?;
186
187        log::debug!("Acquired indexing lock (PID: {})", pid);
188        Ok(lock_file)
189    }
190
191    /// Release lock file
192    fn release_lock(&self) -> Result<()> {
193        let lock_path = self.cache_path.join(LOCK_FILE);
194
195        if lock_path.exists() {
196            std::fs::remove_file(&lock_path).context("Failed to remove lock file")?;
197            log::debug!("Released indexing lock");
198        }
199
200        Ok(())
201    }
202
203    /// Write current status to status file
204    fn write_status(&mut self) -> Result<()> {
205        self.status.updated_at = chrono::Utc::now().to_rfc3339();
206
207        let status_path = self.cache_path.join(STATUS_FILE);
208        let status_json =
209            serde_json::to_string_pretty(&self.status).context("Failed to serialize status")?;
210
211        std::fs::write(&status_path, status_json).context("Failed to write status file")?;
212
213        Ok(())
214    }
215
216    /// Run the background indexer
217    ///
218    /// This processes all indexed files, parsing symbols and caching them.
219    /// Progress is written to `.reflex/indexing.status` and can be monitored.
220    pub fn run(&mut self) -> Result<()> {
221        let start_time = Instant::now();
222
223        // Acquire lock (fails if already running)
224        let _lock_file = self
225            .acquire_lock()
226            .context("Failed to acquire indexing lock")?;
227
228        // Ensure lock is released even on panic
229        let cache_path = self.cache_path.clone();
230        let _guard = scopeguard::guard((), move |_| {
231            let _ = std::fs::remove_file(cache_path.join(LOCK_FILE));
232        });
233
234        // Run indexing
235        let result = self.run_internal();
236
237        // Update status based on result
238        match result {
239            Ok(()) => {
240                self.status.state = IndexerState::Completed;
241                self.status.completed_at = Some(chrono::Utc::now().to_rfc3339());
242                log::info!(
243                    "Symbol indexing completed: {} files processed ({} cached, {} parsed, {} failed) in {:.2}s",
244                    self.status.processed_files,
245                    self.status.cached_files,
246                    self.status.parsed_files,
247                    self.status.failed_files,
248                    start_time.elapsed().as_secs_f64()
249                );
250            }
251            Err(ref e) => {
252                self.status.state = IndexerState::Failed;
253                self.status.error = Some(format!("{:#}", e));
254                self.status.completed_at = Some(chrono::Utc::now().to_rfc3339());
255                log::error!("Symbol indexing failed: {:#}", e);
256            }
257        }
258
259        // Write final status
260        self.write_status()?;
261
262        // Release lock
263        self.release_lock()?;
264
265        result
266    }
267
268    /// Internal indexing implementation with parallel processing
269    fn run_internal(&mut self) -> Result<()> {
270        log::info!("Starting background symbol indexing");
271
272        // Calculate thread pool size (25-30% of available CPUs)
273        let num_cpus = num_cpus::get();
274        let num_threads = ((num_cpus as f32 * 0.275).ceil() as usize).max(1);
275
276        log::info!(
277            "Using {} threads for background indexing ({} CPUs available, ~27.5% utilization)",
278            num_threads,
279            num_cpus
280        );
281
282        // Create custom thread pool with limited threads
283        let thread_pool = rayon::ThreadPoolBuilder::new()
284            .num_threads(num_threads)
285            .build()
286            .context("Failed to create thread pool")?;
287
288        // Open cache manager and symbol cache
289        let cache_mgr = CacheManager::new(&self.workspace_path);
290        let symbol_cache =
291            SymbolCache::open(&self.cache_path).context("Failed to open symbol cache")?;
292
293        // Load content reader to iterate through all indexed files
294        let content_path = self.cache_path.join("content.bin");
295
296        // If content.bin doesn't exist, index is empty - nothing to do
297        if !content_path.exists() {
298            log::info!("No content.bin found - index is empty, nothing to process");
299            self.status.total_files = 0;
300            self.status.processed_files = 0;
301            self.write_status()?;
302            return Ok(());
303        }
304
305        let content_reader =
306            ContentReader::open(&content_path).context("Failed to open content.bin")?;
307
308        // Get file hashes across all branches (background indexer processes all files)
309        let file_hashes = cache_mgr
310            .load_all_hashes()
311            .context("Failed to load file hashes")?;
312
313        let total_files = content_reader.file_count();
314        self.status.total_files = total_files;
315        log::info!("Found {} indexed files to process", total_files);
316        log::debug!(
317            "Loaded {} file hashes from file_branches table",
318            file_hashes.len()
319        );
320
321        // DEFENSIVE CHECK: If file_hashes is empty but we have files, this indicates a problem
322        if file_hashes.is_empty() && total_files > 0 {
323            log::error!(
324                "CRITICAL: No file hashes found in file_branches table, but {} files exist in content.bin!",
325                total_files
326            );
327            log::error!("This likely means:");
328            log::error!("  1. The main indexer failed to populate file_branches table");
329            log::error!("  2. WAL checkpoint didn't flush data before background indexer started");
330            log::error!("  3. Database transaction was rolled back");
331
332            // Try to diagnose by checking database directly
333            log::error!("Attempting diagnostic query to check file_branches table...");
334            anyhow::bail!(
335                "No file hashes available - cannot index symbols. \
336                 This is a database synchronization issue. \
337                 Try running 'rfx index' again or clearing the cache with 'rfx clear'."
338            );
339        }
340
341        // Write initial status
342        self.write_status()?;
343
344        // Shared state for status tracking
345        let status_mutex = Arc::new(Mutex::new((0usize, 0usize, 0usize))); // (cached, parsed, failed)
346
347        // Process files in batches
348        let batch_size = self.batch_size;
349        let mut processed = 0;
350
351        // Iterate through all files in content.bin
352        let file_ids: Vec<u32> = (0..total_files as u32).collect();
353
354        // DIAGNOSTIC: Log sample paths to debug hash lookup failures
355        if !file_ids.is_empty() && !file_hashes.is_empty() {
356            // Log first 3 paths from content.bin
357            log::debug!("=== Path Comparison Diagnostic ===");
358            for sample_id in file_ids.iter().take(3) {
359                if let Some(path) = content_reader.get_file_path(*sample_id) {
360                    log::debug!(
361                        "  content.bin path[{}]: '{}'",
362                        sample_id,
363                        path.to_string_lossy()
364                    );
365                }
366            }
367            // Log first 3 keys from file_hashes HashMap
368            let sample_keys: Vec<_> = file_hashes.keys().take(3).collect();
369            for key in sample_keys {
370                log::debug!("  file_hashes key: '{}'", key);
371            }
372            log::debug!("=================================");
373        }
374
375        for chunk in file_ids.chunks(batch_size) {
376            // Build list of files to parse (with cache check)
377            let files_to_parse: Vec<_> = chunk
378                .iter()
379                .filter_map(|&file_id| {
380                    let path = content_reader.get_file_path(file_id)?;
381                    let mut path_str = path.to_string_lossy().to_string();
382
383                    // NORMALIZE: Strip "./" prefix to match database paths
384                    // content.bin stores paths like "./src/main.rs"
385                    // but database stores paths like "src/main.rs"
386                    if path_str.starts_with("./") {
387                        path_str = path_str[2..].to_string();
388                    }
389
390                    let file_hash = file_hashes.get(&path_str)?;
391
392                    // Check if already cached
393                    if symbol_cache
394                        .get(&path_str, file_hash)
395                        .ok()
396                        .flatten()
397                        .is_some()
398                    {
399                        // Update cached count
400                        let mut status = status_mutex.lock().unwrap();
401                        status.0 += 1;
402                        None
403                    } else {
404                        Some((file_id, path_str, file_hash.clone()))
405                    }
406                })
407                .collect();
408
409            // Parse files in parallel using custom thread pool
410            let parsed_results: Vec<_> = thread_pool.install(|| {
411                files_to_parse
412                    .par_iter()
413                    .map(|(file_id, path_str, file_hash)| {
414                        match self.parse_symbols(&content_reader, *file_id, path_str) {
415                            Ok(symbols) => {
416                                // Update parsed count
417                                let mut status = status_mutex.lock().unwrap();
418                                status.1 += 1;
419                                Some((path_str.clone(), file_hash.clone(), symbols))
420                            }
421                            Err(e) => {
422                                log::warn!("Failed to parse symbols from {}: {}", path_str, e);
423                                // Update failed count
424                                let mut status = status_mutex.lock().unwrap();
425                                status.2 += 1;
426                                None
427                            }
428                        }
429                    })
430                    .flatten()
431                    .collect()
432            });
433
434            // Write batch to cache (sequential - SQLite limitation)
435            if !parsed_results.is_empty()
436                && let Err(e) = symbol_cache.batch_set(&parsed_results)
437            {
438                log::error!("Failed to write symbol batch: {}", e);
439                let mut status = status_mutex.lock().unwrap();
440                status.2 += parsed_results.len();
441            }
442
443            // Update status counters
444            processed += chunk.len();
445            {
446                let status = status_mutex.lock().unwrap();
447                self.status.cached_files = status.0;
448                self.status.parsed_files = status.1;
449                self.status.failed_files = status.2;
450                self.status.processed_files = processed;
451            }
452
453            // Write status every batch
454            if processed % 500 < batch_size
455                && let Err(e) = self.write_status()
456            {
457                log::warn!("Failed to write status: {}", e);
458            }
459        }
460
461        // Final status update
462        self.status.processed_files = total_files;
463        self.write_status()?;
464
465        // Cleanup stale entries
466        let removed = symbol_cache
467            .cleanup_stale()
468            .context("Failed to cleanup stale symbols")?;
469
470        if removed > 0 {
471            log::info!("Cleaned up {} stale symbol entries", removed);
472        }
473
474        Ok(())
475    }
476
477    /// Parse symbols from a file using content.bin
478    fn parse_symbols(
479        &self,
480        content_reader: &ContentReader,
481        file_id: u32,
482        path: &str,
483    ) -> Result<Vec<crate::models::SearchResult>> {
484        // Read file contents from content.bin (memory-mapped, zero-copy)
485        let source = content_reader
486            .get_file_content(file_id)
487            .with_context(|| format!("Failed to read file from content.bin: {}", path))?;
488
489        // Detect language from file extension
490        let extension = std::path::Path::new(path)
491            .extension()
492            .and_then(|e| e.to_str())
493            .unwrap_or("");
494
495        let language = crate::models::Language::from_extension(extension);
496
497        // Parse with appropriate parser
498        let symbols = ParserFactory::parse(path, source, language)
499            .with_context(|| format!("Failed to parse symbols from: {}", path))?;
500
501        Ok(symbols)
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::cache::CacheManager;
509    use tempfile::TempDir;
510
511    #[test]
512    fn test_indexer_lock() {
513        let temp = TempDir::new().unwrap();
514        let cache_mgr = CacheManager::new(temp.path());
515        cache_mgr.init().unwrap();
516
517        assert!(!BackgroundIndexer::is_running(cache_mgr.path()));
518
519        let indexer = BackgroundIndexer::new(temp.path()).unwrap();
520        let _lock = indexer.acquire_lock().unwrap();
521
522        assert!(BackgroundIndexer::is_running(cache_mgr.path()));
523
524        indexer.release_lock().unwrap();
525        assert!(!BackgroundIndexer::is_running(cache_mgr.path()));
526    }
527
528    #[test]
529    fn test_indexer_lock_prevents_concurrent() {
530        let temp = TempDir::new().unwrap();
531        let cache_mgr = CacheManager::new(temp.path());
532        cache_mgr.init().unwrap();
533
534        let indexer1 = BackgroundIndexer::new(temp.path()).unwrap();
535        let _lock1 = indexer1.acquire_lock().unwrap();
536
537        let indexer2 = BackgroundIndexer::new(temp.path()).unwrap();
538        let result = indexer2.acquire_lock();
539
540        assert!(result.is_err());
541        assert!(
542            result
543                .unwrap_err()
544                .to_string()
545                .contains("already in progress")
546        );
547    }
548
549    #[test]
550    fn test_indexer_status_write() {
551        let temp = TempDir::new().unwrap();
552        let cache_mgr = CacheManager::new(temp.path());
553        cache_mgr.init().unwrap();
554
555        let mut indexer = BackgroundIndexer::new(temp.path()).unwrap();
556        indexer.status.total_files = 100;
557        indexer.status.processed_files = 50;
558
559        indexer.write_status().unwrap();
560
561        let status = BackgroundIndexer::get_status(cache_mgr.path()).unwrap();
562        assert!(status.is_some());
563
564        let status = status.unwrap();
565        assert_eq!(status.total_files, 100);
566        assert_eq!(status.processed_files, 50);
567        assert_eq!(status.state, IndexerState::Running);
568    }
569
570    #[test]
571    fn test_indexer_status_read_nonexistent() {
572        let temp = TempDir::new().unwrap();
573        let cache_mgr = CacheManager::new(temp.path());
574        cache_mgr.init().unwrap();
575
576        let status = BackgroundIndexer::get_status(cache_mgr.path()).unwrap();
577        assert!(status.is_none());
578    }
579
580    #[test]
581    fn test_indexer_run_empty_index() {
582        let temp = TempDir::new().unwrap();
583        let cache_mgr = CacheManager::new(temp.path());
584        cache_mgr.init().unwrap();
585
586        let mut indexer = BackgroundIndexer::new(temp.path()).unwrap();
587        let result = indexer.run();
588
589        assert!(result.is_ok());
590        assert_eq!(indexer.status.state, IndexerState::Completed);
591        assert_eq!(indexer.status.processed_files, 0);
592        assert_eq!(indexer.status.total_files, 0);
593    }
594
595    #[test]
596    fn test_stale_lock_detection() {
597        use filetime::{FileTime, set_file_mtime};
598
599        let temp = TempDir::new().unwrap();
600        let cache_mgr = CacheManager::new(temp.path());
601        cache_mgr.init().unwrap();
602
603        let lock_path = cache_mgr.path().join(LOCK_FILE);
604
605        // Fresh lock file should not be considered stale
606        std::fs::write(&lock_path, "12345").unwrap();
607        assert!(!is_lock_stale(&lock_path), "fresh lock should not be stale");
608
609        // Backdate mtime to 2 hours ago (exceeds LOCK_MAX_AGE of 1 hour)
610        let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600);
611        set_file_mtime(&lock_path, FileTime::from_system_time(two_hours_ago)).unwrap();
612
613        assert!(is_lock_stale(&lock_path), "2-hour-old lock should be stale");
614
615        // Nonexistent lock file should not be reported as stale
616        std::fs::remove_file(&lock_path).unwrap();
617        assert!(
618            !is_lock_stale(&lock_path),
619            "missing lock should not be stale"
620        );
621    }
622
623    #[test]
624    fn test_is_running_cleans_stale_lock() {
625        use filetime::{FileTime, set_file_mtime};
626
627        let temp = TempDir::new().unwrap();
628        let cache_mgr = CacheManager::new(temp.path());
629        cache_mgr.init().unwrap();
630
631        let lock_path = cache_mgr.path().join(LOCK_FILE);
632
633        // Create a stale lock file (backdated 2 hours)
634        std::fs::write(&lock_path, "99999999").unwrap();
635        let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600);
636        set_file_mtime(&lock_path, FileTime::from_system_time(two_hours_ago)).unwrap();
637
638        assert!(
639            lock_path.exists(),
640            "lock file should exist before is_running()"
641        );
642
643        // is_running() should detect staleness, remove the lock, and return false
644        assert!(!BackgroundIndexer::is_running(cache_mgr.path()));
645        assert!(
646            !lock_path.exists(),
647            "stale lock file should be removed by is_running()"
648        );
649    }
650
651    #[test]
652    fn test_acquire_lock_cleans_stale_lock() {
653        use filetime::{FileTime, set_file_mtime};
654
655        let temp = TempDir::new().unwrap();
656        let cache_mgr = CacheManager::new(temp.path());
657        cache_mgr.init().unwrap();
658
659        let lock_path = cache_mgr.path().join(LOCK_FILE);
660
661        // Create a stale lock file
662        std::fs::write(&lock_path, "99999999").unwrap();
663        let two_hours_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600);
664        set_file_mtime(&lock_path, FileTime::from_system_time(two_hours_ago)).unwrap();
665
666        // acquire_lock() should succeed by treating the stale lock as removable
667        let indexer = BackgroundIndexer::new(temp.path()).unwrap();
668        let _lock = indexer
669            .acquire_lock()
670            .expect("stale lock should not block acquire_lock");
671
672        assert!(lock_path.exists(), "new lock file should be created");
673    }
674}