1use 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
21const LOCK_FILE: &str = "indexing.lock";
23
24const LOCK_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(3600);
26
27const STATUS_FILE: &str = "indexing.status";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct IndexingStatus {
33 pub state: IndexerState,
35 pub total_files: usize,
37 pub processed_files: usize,
39 pub cached_files: usize,
41 pub parsed_files: usize,
43 pub failed_files: usize,
45 pub started_at: String,
47 pub updated_at: String,
49 pub completed_at: Option<String>,
51 pub error: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum IndexerState {
59 Running,
61 Completed,
63 Failed,
65}
66
67fn is_lock_stale(lock_path: &Path) -> bool {
73 let metadata = match std::fs::metadata(lock_path) {
74 Ok(m) => m,
75 Err(_) => return false, };
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, }
85}
86
87pub struct BackgroundIndexer {
89 workspace_path: PathBuf,
90 cache_path: PathBuf,
91 status: IndexingStatus,
92 batch_size: usize,
93}
94
95impl BackgroundIndexer {
96 pub fn new(workspace_path: &Path) -> Result<Self> {
101 let now = chrono::Utc::now().to_rfc3339();
102
103 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, })
124 }
125
126 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 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 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 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 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 pub fn run(&mut self) -> Result<()> {
221 let start_time = Instant::now();
222
223 let _lock_file = self
225 .acquire_lock()
226 .context("Failed to acquire indexing lock")?;
227
228 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 let result = self.run_internal();
236
237 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 self.write_status()?;
261
262 self.release_lock()?;
264
265 result
266 }
267
268 fn run_internal(&mut self) -> Result<()> {
270 log::info!("Starting background symbol indexing");
271
272 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 let thread_pool = rayon::ThreadPoolBuilder::new()
284 .num_threads(num_threads)
285 .build()
286 .context("Failed to create thread pool")?;
287
288 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 let content_path = self.cache_path.join("content.bin");
295
296 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 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 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 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 self.write_status()?;
343
344 let status_mutex = Arc::new(Mutex::new((0usize, 0usize, 0usize))); let batch_size = self.batch_size;
349 let mut processed = 0;
350
351 let file_ids: Vec<u32> = (0..total_files as u32).collect();
353
354 if !file_ids.is_empty() && !file_hashes.is_empty() {
356 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 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 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 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 if symbol_cache
394 .get(&path_str, file_hash)
395 .ok()
396 .flatten()
397 .is_some()
398 {
399 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 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 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 let mut status = status_mutex.lock().unwrap();
425 status.2 += 1;
426 None
427 }
428 }
429 })
430 .flatten()
431 .collect()
432 });
433
434 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 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 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 self.status.processed_files = total_files;
463 self.write_status()?;
464
465 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 fn parse_symbols(
479 &self,
480 content_reader: &ContentReader,
481 file_id: u32,
482 path: &str,
483 ) -> Result<Vec<crate::models::SearchResult>> {
484 let source = content_reader
486 .get_file_content(file_id)
487 .with_context(|| format!("Failed to read file from content.bin: {}", path))?;
488
489 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 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 std::fs::write(&lock_path, "12345").unwrap();
607 assert!(!is_lock_stale(&lock_path), "fresh lock should not be stale");
608
609 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 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 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 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 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 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}