2026-08-02 19:04:30 -04:00
|
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
|
|
|
use std::sync::{Arc, Mutex, mpsc};
|
|
|
|
|
use std::thread;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use rusqlite::{params, Connection, OptionalExtension};
|
|
|
|
|
|
|
|
|
|
use crate::extract::Registry;
|
|
|
|
|
use crate::file_handling::{
|
|
|
|
|
cleanup_stale_index_entries,
|
|
|
|
|
count_tree_entries_fast,
|
|
|
|
|
extract_one_batch,
|
|
|
|
|
extract_scope_prepare,
|
|
|
|
|
fts_finalize_after_text_indexing,
|
|
|
|
|
load_existing_files,
|
|
|
|
|
process_batch_inserts,
|
|
|
|
|
process_batch_updates,
|
|
|
|
|
path_to_db_string,
|
|
|
|
|
ExtractCursor,
|
|
|
|
|
FileIndexAction,
|
|
|
|
|
OwnedNewFile,
|
|
|
|
|
};
|
|
|
|
|
use crate::config::Config;
|
|
|
|
|
use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WorkerStats};
|
|
|
|
|
use crate::db;
|
|
|
|
|
|
|
|
|
|
/// Where one root's pipeline is in its life cycle.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum RootPhase {
|
|
|
|
|
/// The parallel walk is discovering and writing file metadata.
|
|
|
|
|
Walking,
|
|
|
|
|
/// The walk finished; content extraction is draining this root's
|
|
|
|
|
/// pending rows.
|
|
|
|
|
Extracting,
|
|
|
|
|
Done,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Progress for one indexing root. Each root runs its own walker and its
|
|
|
|
|
/// own extraction cursor; the GUI shows one row per root.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct RootProgress {
|
|
|
|
|
pub root: String,
|
|
|
|
|
pub phase: RootPhase,
|
|
|
|
|
/// Files the walk has seen so far.
|
|
|
|
|
pub walked: usize,
|
|
|
|
|
/// Concurrent `find`-based denominator; `None` until the count lands.
|
|
|
|
|
pub walk_total: Option<usize>,
|
|
|
|
|
/// Rows with searchable text: extracted in earlier runs plus this one.
|
|
|
|
|
pub extracted: usize,
|
|
|
|
|
/// The root's whole searchable set: pending + already-extracted rows
|
|
|
|
|
/// at the moment the walk finished.
|
|
|
|
|
pub extract_total: usize,
|
|
|
|
|
pub current_file: Option<String>,
|
|
|
|
|
/// Walker threads busy right now / pool size.
|
|
|
|
|
pub active_workers: usize,
|
|
|
|
|
pub total_workers: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub enum IndexingStatus {
|
|
|
|
|
Idle,
|
|
|
|
|
Running {
|
|
|
|
|
start_time: Instant,
|
|
|
|
|
roots: Vec<RootProgress>,
|
|
|
|
|
},
|
|
|
|
|
Stopping,
|
|
|
|
|
Error(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One setting whose stored (index-build-time) value differs from the
|
|
|
|
|
/// current config. Values that hold lists (roots, patterns, extensions)
|
|
|
|
|
/// are newline-joined — display them as multi-line columns, not inline.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ConfigChange {
|
|
|
|
|
pub key: String,
|
|
|
|
|
/// What the index was built with.
|
|
|
|
|
pub stored: String,
|
|
|
|
|
/// What the config says now.
|
|
|
|
|
pub current: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub enum IndexingCommand {
|
|
|
|
|
Start {
|
|
|
|
|
/// One or more directory roots to index. Order determines walk order;
|
|
|
|
|
/// duplicates are silently dropped at run time.
|
|
|
|
|
paths: Vec<String>,
|
|
|
|
|
db_path: String,
|
|
|
|
|
config: Config,
|
|
|
|
|
},
|
|
|
|
|
Stop,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct IndexingService {
|
|
|
|
|
status: Arc<Mutex<IndexingStatus>>,
|
|
|
|
|
command_tx: mpsc::Sender<IndexingCommand>,
|
|
|
|
|
db_connection: Arc<Mutex<Option<Arc<Mutex<Connection>>>>>,
|
|
|
|
|
suspend_flag: Arc<AtomicBool>,
|
|
|
|
|
_handle: thread::JoinHandle<()>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Polling interval for `should_abort` while suspended.
|
|
|
|
|
const SUSPEND_POLL_MS: u64 = 100;
|
|
|
|
|
|
|
|
|
|
/// Combined stop/suspend check used by worker loops. Returns `true` iff the
|
|
|
|
|
/// caller should abort the operation. While the suspend flag is set and stop
|
|
|
|
|
/// is not, this parks the thread by sleeping in short increments so a later
|
|
|
|
|
/// `resume()` unblocks it. Cheap to call in tight loops.
|
|
|
|
|
pub(crate) fn should_abort(
|
|
|
|
|
stop: &Arc<Mutex<bool>>,
|
|
|
|
|
suspend: &Arc<AtomicBool>,
|
|
|
|
|
) -> bool {
|
|
|
|
|
loop {
|
|
|
|
|
if *stop.lock().unwrap() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if !suspend.load(Ordering::Relaxed) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
thread::sleep(Duration::from_millis(SUSPEND_POLL_MS));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Set process priority for background operation
|
|
|
|
|
// fn set_background_priority() {
|
|
|
|
|
// #[cfg(windows)]
|
|
|
|
|
// {
|
|
|
|
|
// use std::os::windows::raw::HANDLE;
|
|
|
|
|
|
|
|
|
|
// // Windows implementation
|
|
|
|
|
// extern "system" {
|
|
|
|
|
// fn GetCurrentProcess() -> HANDLE;
|
|
|
|
|
// fn SetPriorityClass(hprocess: HANDLE, dwpriorityclass: u32) -> i32;
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x00004000;
|
|
|
|
|
// unsafe {
|
|
|
|
|
// SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS);
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// #[cfg(unix)]
|
|
|
|
|
// {
|
|
|
|
|
// // Unix implementation
|
|
|
|
|
// use std::os::unix::process::CommandExt;
|
|
|
|
|
// unsafe {
|
|
|
|
|
// libc::nice(10); // Lower priority
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
/// Flips an [`AtomicBool`] when dropped. Held by `run_indexing` so the
|
|
|
|
|
/// per-root count subprocesses die on every exit path of a run.
|
|
|
|
|
struct CancelOnDrop(Arc<AtomicBool>);
|
|
|
|
|
|
|
|
|
|
impl Drop for CancelOnDrop {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
self.0.store(true, Ordering::Relaxed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One root's in-flight indexing state, owned by the writer loop.
|
|
|
|
|
struct RootPipeline {
|
|
|
|
|
root: String,
|
|
|
|
|
walk: ParallelWalk,
|
|
|
|
|
stats: WorkerStats,
|
|
|
|
|
/// Concurrent `find` count; 0 = not yet known.
|
|
|
|
|
count_total: Arc<AtomicUsize>,
|
|
|
|
|
pending_updates: Vec<OwnedNewFile>,
|
|
|
|
|
pending_inserts: Vec<OwnedNewFile>,
|
|
|
|
|
walked: usize,
|
|
|
|
|
walk_clean: bool,
|
|
|
|
|
phase: RootPhase,
|
|
|
|
|
extract: Option<ExtractCursor>,
|
|
|
|
|
extract_total: usize,
|
|
|
|
|
extracted: usize,
|
|
|
|
|
current_file: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IndexingService {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
let status = Arc::new(Mutex::new(IndexingStatus::Idle));
|
|
|
|
|
let (command_tx, command_rx) = mpsc::channel();
|
|
|
|
|
let db_connection = Arc::new(Mutex::new(None));
|
|
|
|
|
let suspend_flag = Arc::new(AtomicBool::new(false));
|
|
|
|
|
|
|
|
|
|
let status_clone = status.clone();
|
|
|
|
|
let db_connection_clone = db_connection.clone();
|
|
|
|
|
let suspend_clone = suspend_flag.clone();
|
|
|
|
|
let handle = thread::spawn(move || {
|
|
|
|
|
Self::indexing_thread(status_clone, command_rx, db_connection_clone, suspend_clone);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
IndexingService {
|
|
|
|
|
status,
|
|
|
|
|
command_tx,
|
|
|
|
|
db_connection,
|
|
|
|
|
suspend_flag,
|
|
|
|
|
_handle: handle,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pause the indexer. All worker loops that call [`should_abort`] will
|
|
|
|
|
/// block until [`resume`](Self::resume) is called. No-op if already
|
|
|
|
|
/// suspended. Does not stop the worker — stop_indexing is still the way
|
|
|
|
|
/// to abort.
|
|
|
|
|
pub fn suspend(&self) {
|
|
|
|
|
self.suspend_flag.store(true, Ordering::Relaxed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resume indexing after [`suspend`](Self::suspend). No-op if not
|
|
|
|
|
/// suspended.
|
|
|
|
|
pub fn resume(&self) {
|
|
|
|
|
self.suspend_flag.store(false, Ordering::Relaxed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn is_suspended(&self) -> bool {
|
|
|
|
|
self.suspend_flag.load(Ordering::Relaxed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start indexing one or more roots. Paths are walked in order; duplicate
|
|
|
|
|
/// or nested roots are de-duplicated by the indexer. At least one path is
|
|
|
|
|
/// required.
|
|
|
|
|
pub fn start_indexing(
|
|
|
|
|
&self,
|
|
|
|
|
paths: Vec<String>,
|
|
|
|
|
db_path: String,
|
|
|
|
|
config: Config,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
if paths.is_empty() {
|
|
|
|
|
return Err("start_indexing requires at least one path".into());
|
|
|
|
|
}
|
|
|
|
|
self.command_tx
|
|
|
|
|
.send(IndexingCommand::Start { paths, db_path, config })
|
|
|
|
|
.map_err(|e| format!("Failed to send start command: {}", e))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Signal a running index pass to stop without waiting for it. Used
|
|
|
|
|
/// on shutdown paths that must stay responsive — the worker notices
|
|
|
|
|
/// the flag between batches and WAL makes an unflushed exit safe.
|
|
|
|
|
pub fn request_stop(&self) {
|
|
|
|
|
let _ = self.command_tx.send(IndexingCommand::Stop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn stop_indexing(&self) -> Result<(), String> {
|
|
|
|
|
// First send the stop command
|
|
|
|
|
self.command_tx
|
|
|
|
|
.send(IndexingCommand::Stop)
|
|
|
|
|
.map_err(|e| format!("Failed to send stop command: {}", e))?;
|
|
|
|
|
|
|
|
|
|
// Wait for indexing to transition to stopping state
|
|
|
|
|
let mut attempts = 0;
|
|
|
|
|
while attempts < 50 { // Wait up to 5 seconds
|
|
|
|
|
match self.get_status() {
|
|
|
|
|
IndexingStatus::Stopping => break,
|
|
|
|
|
IndexingStatus::Idle => return Ok(()), // Already stopped
|
|
|
|
|
IndexingStatus::Error(_) => return Ok(()), // Consider error state as stopped
|
|
|
|
|
_ => {
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
|
attempts += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flush the WAL and release the shared connection. WAL mode itself
|
|
|
|
|
// stays on — it's the persistent journal mode for the index.
|
|
|
|
|
if let Ok(mut db_opt) = self.db_connection.lock() {
|
|
|
|
|
if let Some(db_conn_arc) = db_opt.take() {
|
|
|
|
|
if let Ok(conn) = db_conn_arc.lock() {
|
|
|
|
|
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", ());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get_status(&self) -> IndexingStatus {
|
|
|
|
|
self.status.lock().unwrap().clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Force graceful shutdown - used for signal handling
|
|
|
|
|
pub fn graceful_shutdown(&self) -> Result<(), String> {
|
|
|
|
|
self.stop_indexing()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if configuration changes require index recreation. A pure
|
|
|
|
|
/// *read* check: opens the existing index without ever wiping it (the
|
|
|
|
|
/// old `open_or_recreate` here could destroy the index before the user
|
|
|
|
|
/// confirmed the rebuild dialog). A missing or incompatible DB means
|
|
|
|
|
/// there is nothing to validate — the indexer will (re)build under its
|
|
|
|
|
/// own policy anyway.
|
|
|
|
|
pub fn check_config_validation(&self, db_path: &str, config: &Config, indexing_path: &str) -> Result<Option<Vec<ConfigChange>>, String> {
|
|
|
|
|
match db::open_existing(db_path, false) {
|
|
|
|
|
Ok(conn) => Self::validate_config(&conn, config, indexing_path),
|
|
|
|
|
Err(_) => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stop indexing and delete the database file for a clean rebuild
|
|
|
|
|
pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> {
|
|
|
|
|
// Stop any running indexing first
|
|
|
|
|
self.stop_indexing()
|
|
|
|
|
.map_err(|e| format!("Failed to stop indexing: {}", e))?;
|
|
|
|
|
|
|
|
|
|
// Wait for indexing to actually stop
|
|
|
|
|
let mut attempts = 0;
|
|
|
|
|
while attempts < 50 { // Wait up to 5 seconds
|
|
|
|
|
match self.get_status() {
|
|
|
|
|
IndexingStatus::Idle => break,
|
|
|
|
|
IndexingStatus::Stopping | IndexingStatus::Running { .. } => {
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
|
attempts += 1;
|
|
|
|
|
}
|
|
|
|
|
IndexingStatus::Error(_) => break, // Consider error state as stopped
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete the database file and its WAL sidecars.
|
|
|
|
|
if std::path::Path::new(db_path).exists() {
|
|
|
|
|
std::fs::remove_file(db_path)
|
|
|
|
|
.map_err(|e| format!("Failed to delete database file: {}", e))?;
|
|
|
|
|
}
|
|
|
|
|
for suffix in ["-wal", "-shm", "-journal"] {
|
|
|
|
|
let _ = std::fs::remove_file(format!("{}{}", db_path, suffix));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn indexing_thread(
|
|
|
|
|
status: Arc<Mutex<IndexingStatus>>,
|
|
|
|
|
command_rx: mpsc::Receiver<IndexingCommand>,
|
|
|
|
|
db_connection: Arc<Mutex<Option<Arc<Mutex<Connection>>>>>,
|
|
|
|
|
suspend_flag: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
let stop_flag = Arc::new(Mutex::new(false));
|
|
|
|
|
let mut indexing_handle: Option<thread::JoinHandle<()>> = None;
|
|
|
|
|
|
|
|
|
|
while let Ok(command) = command_rx.recv() {
|
|
|
|
|
match command {
|
|
|
|
|
IndexingCommand::Start { paths, db_path, config } => {
|
|
|
|
|
if matches!(*status.lock().unwrap(), IndexingStatus::Running { .. }) {
|
|
|
|
|
continue; // Already running
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Join any previous indexing thread
|
|
|
|
|
if let Some(handle) = indexing_handle.take() {
|
|
|
|
|
let _ = handle.join();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*stop_flag.lock().unwrap() = false;
|
|
|
|
|
// One placeholder row per root so the GUI has structure
|
|
|
|
|
// to draw before the writer loop publishes real numbers.
|
|
|
|
|
*status.lock().unwrap() = IndexingStatus::Running {
|
|
|
|
|
start_time: Instant::now(),
|
|
|
|
|
roots: paths
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| RootProgress {
|
|
|
|
|
root: p.clone(),
|
|
|
|
|
phase: RootPhase::Walking,
|
|
|
|
|
walked: 0,
|
|
|
|
|
walk_total: None,
|
|
|
|
|
extracted: 0,
|
|
|
|
|
extract_total: 0,
|
|
|
|
|
current_file: Some("Starting…".to_string()),
|
|
|
|
|
active_workers: 0,
|
|
|
|
|
total_workers: 0,
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Run indexing in a separate thread
|
|
|
|
|
let status_clone = status.clone();
|
|
|
|
|
let stop_flag_clone = stop_flag.clone();
|
|
|
|
|
let paths_owned = paths.clone();
|
|
|
|
|
let db_path_owned = db_path.clone();
|
|
|
|
|
let config_owned = config.clone();
|
|
|
|
|
|
|
|
|
|
let db_connection_clone = db_connection.clone();
|
|
|
|
|
let suspend_clone = suspend_flag.clone();
|
|
|
|
|
indexing_handle = Some(thread::spawn(move || {
|
|
|
|
|
if let Err(e) = Self::run_indexing(&status_clone, &paths_owned, &db_path_owned, &stop_flag_clone, &suspend_clone, &config_owned, &db_connection_clone) {
|
|
|
|
|
*status_clone.lock().unwrap() = IndexingStatus::Error(e);
|
|
|
|
|
} else {
|
|
|
|
|
// Only set to Idle if we weren't stopped
|
|
|
|
|
if !*stop_flag_clone.lock().unwrap() {
|
|
|
|
|
*status_clone.lock().unwrap() = IndexingStatus::Idle;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear the database connection when indexing completes
|
|
|
|
|
if let Ok(mut db_opt) = db_connection_clone.lock() {
|
|
|
|
|
*db_opt = None;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
IndexingCommand::Stop => {
|
|
|
|
|
if matches!(*status.lock().unwrap(), IndexingStatus::Running { .. }) {
|
|
|
|
|
*status.lock().unwrap() = IndexingStatus::Stopping;
|
|
|
|
|
*stop_flag.lock().unwrap() = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean up any remaining indexing thread
|
|
|
|
|
if let Some(handle) = indexing_handle {
|
|
|
|
|
let _ = handle.join();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_indexing(
|
|
|
|
|
status: &Arc<Mutex<IndexingStatus>>,
|
|
|
|
|
paths: &[String],
|
|
|
|
|
db_path: &str,
|
|
|
|
|
stop_flag: &Arc<Mutex<bool>>,
|
|
|
|
|
suspend_flag: &Arc<AtomicBool>,
|
|
|
|
|
config: &Config,
|
|
|
|
|
db_connection: &Arc<Mutex<Option<Arc<Mutex<Connection>>>>>,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
if paths.is_empty() {
|
|
|
|
|
return Err("run_indexing: no paths provided".into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// De-duplicate while preserving order. Roots are canonicalized first
|
|
|
|
|
// so `/home/jeremy` and `/home/jeremy/` (or a symlink to either)
|
|
|
|
|
// collapse to one walk. Pure nested-root deduplication (skip a root
|
|
|
|
|
// that is a prefix of an already-walked root) is handled by the
|
|
|
|
|
// per-file `seen_paths` set below.
|
|
|
|
|
let mut seen_roots = HashSet::new();
|
|
|
|
|
let roots: Vec<String> = paths
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| {
|
|
|
|
|
std::path::Path::new(p)
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.ok()
|
|
|
|
|
// Same spelling rules as `files.path`: a hand-rolled
|
|
|
|
|
// four-character strip turns `\\?\UNC\server\share` into
|
|
|
|
|
// `UNC\server\share`, which is not a path — and no longer
|
|
|
|
|
// looks like a share, so the root would silently walk with
|
|
|
|
|
// the local thread count instead of the network one.
|
|
|
|
|
.map(|c| path_to_db_string(&c))
|
|
|
|
|
.unwrap_or_else(|| p.clone())
|
|
|
|
|
})
|
|
|
|
|
.filter(|p| seen_roots.insert(p.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Open and migrate the database to the current schema version.
|
|
|
|
|
let conn = db::open_or_recreate(db_path, &config.processing.tokenize)?;
|
|
|
|
|
|
|
|
|
|
// Update configuration (for new installations or when no validation issues).
|
|
|
|
|
// `indexing_path` in the validation table stores the joined list so
|
|
|
|
|
// adding/removing a root triggers the same rebuild prompt as changing
|
|
|
|
|
// the legacy single path did.
|
|
|
|
|
Self::update_config(&conn, config, &roots.join("\n"))?;
|
|
|
|
|
|
|
|
|
|
// Load existing files from database for incremental indexing
|
|
|
|
|
// Shared read-only with the walk threads, which classify against it.
|
|
|
|
|
let existing_files = Arc::new({
|
|
|
|
|
let conn_ref = &conn;
|
|
|
|
|
load_existing_files(conn_ref)
|
|
|
|
|
.map_err(|e| format!("Failed to load existing files: {}", e))?
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let conn_mutex = Arc::new(Mutex::new(conn));
|
|
|
|
|
|
|
|
|
|
// Store the database connection for proper cleanup on stop
|
|
|
|
|
if let Ok(mut db_opt) = db_connection.lock() {
|
|
|
|
|
*db_opt = Some(conn_mutex.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let run_start = Instant::now();
|
|
|
|
|
|
|
|
|
|
// Per-root concurrent counts, killed the moment this run exits by
|
|
|
|
|
// any path — the guard flips the token on drop and the count
|
|
|
|
|
// threads' subprocesses die within one poll interval.
|
|
|
|
|
let count_cancel = Arc::new(AtomicBool::new(false));
|
|
|
|
|
let _count_guard = CancelOnDrop(count_cancel.clone());
|
|
|
|
|
|
|
|
|
|
// Shared with every root's walk workers, which use it to finish small
|
|
|
|
|
// text files without handing them to the content pass below.
|
|
|
|
|
let registry = Arc::new(Registry::default_set());
|
|
|
|
|
let quantum = config.processing.batch_size.max(1);
|
|
|
|
|
|
|
|
|
|
// One pipeline per root: its own walker (with per-root worker
|
|
|
|
|
// count), its own count thread, its own buffers and extraction
|
|
|
|
|
// cursor. They all funnel into this single writer thread.
|
|
|
|
|
let mut pipelines: Vec<RootPipeline> = Vec::with_capacity(roots.len());
|
|
|
|
|
for root in &roots {
|
|
|
|
|
let ignore = crate::config::IgnoreSet::compile(&config.indexing.ignore_patterns)
|
|
|
|
|
.map_err(|e| format!("ignore patterns: {}", e))?;
|
|
|
|
|
let workers = config
|
|
|
|
|
.indexing
|
|
|
|
|
.root_workers
|
|
|
|
|
.get(root)
|
|
|
|
|
.copied()
|
|
|
|
|
.filter(|w| *w > 0)
|
|
|
|
|
.unwrap_or_else(|| thread_count_for(std::slice::from_ref(root)))
|
|
|
|
|
.clamp(1, 64);
|
|
|
|
|
let walk = walk_indexable_files(
|
|
|
|
|
std::slice::from_ref(root),
|
|
|
|
|
config.indexing.follow_symlinks,
|
|
|
|
|
config.indexing.include_hidden,
|
|
|
|
|
ignore,
|
|
|
|
|
existing_files.clone(),
|
|
|
|
|
config.clone(),
|
|
|
|
|
registry.clone(),
|
|
|
|
|
stop_flag.clone(),
|
|
|
|
|
suspend_flag.clone(),
|
|
|
|
|
workers,
|
|
|
|
|
);
|
|
|
|
|
let stats = walk.worker_stats();
|
|
|
|
|
|
|
|
|
|
let count_total = Arc::new(AtomicUsize::new(0));
|
|
|
|
|
{
|
|
|
|
|
let root = root.clone();
|
|
|
|
|
let cancel = count_cancel.clone();
|
|
|
|
|
let total = count_total.clone();
|
|
|
|
|
let _ = thread::Builder::new().name("qs-count".into()).spawn(move || {
|
|
|
|
|
match count_tree_entries_fast(&root, &cancel) {
|
|
|
|
|
// A genuinely empty root stores 1 so "known" stays
|
|
|
|
|
// distinguishable from the 0 = unknown sentinel; an
|
|
|
|
|
// empty root's walk finishes instantly anyway.
|
|
|
|
|
Ok(n) => total.store(n.max(1), Ordering::Relaxed),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if !e.contains("cancelled") {
|
|
|
|
|
crate::log_warn!("count for {}: {}", root, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pipelines.push(RootPipeline {
|
|
|
|
|
root: root.clone(),
|
|
|
|
|
walk,
|
|
|
|
|
stats,
|
|
|
|
|
count_total,
|
|
|
|
|
pending_updates: Vec::new(),
|
|
|
|
|
pending_inserts: Vec::new(),
|
|
|
|
|
walked: 0,
|
|
|
|
|
walk_clean: true,
|
|
|
|
|
phase: RootPhase::Walking,
|
|
|
|
|
extract: None,
|
|
|
|
|
extract_total: 0,
|
|
|
|
|
extracted: 0,
|
|
|
|
|
current_file: None,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Publish a status snapshot. Never clobbers Stopping — the command
|
|
|
|
|
// thread owns that transition.
|
|
|
|
|
let publish = |pipelines: &[RootPipeline]| {
|
|
|
|
|
let roots: Vec<RootProgress> = pipelines
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| RootProgress {
|
|
|
|
|
root: p.root.clone(),
|
|
|
|
|
phase: p.phase,
|
|
|
|
|
walked: p.walked,
|
|
|
|
|
walk_total: match p.count_total.load(Ordering::Relaxed) {
|
|
|
|
|
0 => None,
|
|
|
|
|
n => Some(n),
|
|
|
|
|
},
|
|
|
|
|
extracted: p.extracted,
|
|
|
|
|
extract_total: p.extract_total,
|
|
|
|
|
current_file: p.current_file.clone(),
|
|
|
|
|
active_workers: p.stats.active(),
|
|
|
|
|
total_workers: p.stats.total(),
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
if let Ok(mut g) = status.lock() {
|
|
|
|
|
if !matches!(*g, IndexingStatus::Stopping) {
|
|
|
|
|
*g = IndexingStatus::Running { start_time: run_start, roots };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
publish(&pipelines);
|
|
|
|
|
|
|
|
|
|
// Round-robin with skipping: each round takes at most one quantum
|
|
|
|
|
// from every root that has work ready. Write-bottlenecked, all
|
|
|
|
|
// active roots get even quanta; read-bottlenecked, roots with
|
|
|
|
|
// empty channels are skipped and the firehose roots get the
|
|
|
|
|
// writer's full attention.
|
|
|
|
|
let mut seen_paths: HashSet<String> = HashSet::new();
|
|
|
|
|
let mut aborted = false;
|
|
|
|
|
let mut stale_cleanup_ok = true;
|
|
|
|
|
let mut cleanup_done = false;
|
|
|
|
|
let mut stale_deleted = 0usize;
|
|
|
|
|
let mut rr = 0usize;
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
if should_abort(stop_flag, suspend_flag) {
|
|
|
|
|
aborted = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let mut progressed = false;
|
|
|
|
|
let n = pipelines.len();
|
|
|
|
|
for k in 0..n {
|
|
|
|
|
let p = &mut pipelines[(rr + k) % n];
|
|
|
|
|
match p.phase {
|
|
|
|
|
RootPhase::Walking => {
|
|
|
|
|
let mut took = 0usize;
|
|
|
|
|
while took < quantum {
|
|
|
|
|
match p.walk.try_next() {
|
|
|
|
|
TryNext::Item(file) => {
|
|
|
|
|
took += 1;
|
|
|
|
|
p.walked += 1;
|
|
|
|
|
if p.walked % 64 == 0 {
|
|
|
|
|
p.current_file = Some(file.path.clone());
|
|
|
|
|
}
|
|
|
|
|
// Membership decides what survives stale
|
|
|
|
|
// cleanup: "the walk saw this", never
|
|
|
|
|
// "processed successfully". Also dedupes
|
|
|
|
|
// symlinked spellings across roots.
|
|
|
|
|
if !seen_paths.insert(file.path.clone()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(rec) = file.record else { continue };
|
|
|
|
|
if file.action == FileIndexAction::Update {
|
|
|
|
|
p.pending_updates.push(rec);
|
|
|
|
|
if p.pending_updates.len() >= quantum {
|
|
|
|
|
process_batch_updates(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
&p.pending_updates,
|
|
|
|
|
stop_flag,
|
|
|
|
|
config,
|
|
|
|
|
)?;
|
|
|
|
|
p.pending_updates.clear();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
p.pending_inserts.push(rec);
|
|
|
|
|
if p.pending_inserts.len() >= quantum {
|
|
|
|
|
process_batch_inserts(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
&p.pending_inserts,
|
|
|
|
|
stop_flag,
|
|
|
|
|
config,
|
|
|
|
|
)?;
|
|
|
|
|
p.pending_inserts.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TryNext::Empty => break,
|
|
|
|
|
TryNext::Finished => {
|
|
|
|
|
// Join before deciding anything: workers
|
|
|
|
|
// close the channel when they stop for
|
|
|
|
|
// *any* reason, so a panic and a finished
|
|
|
|
|
// walk look identical from here.
|
|
|
|
|
p.walk_clean = p.walk.finish();
|
|
|
|
|
process_batch_updates(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
&p.pending_updates,
|
|
|
|
|
stop_flag,
|
|
|
|
|
config,
|
|
|
|
|
)?;
|
|
|
|
|
p.pending_updates.clear();
|
|
|
|
|
process_batch_inserts(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
&p.pending_inserts,
|
|
|
|
|
stop_flag,
|
|
|
|
|
config,
|
|
|
|
|
)?;
|
|
|
|
|
p.pending_inserts.clear();
|
|
|
|
|
|
|
|
|
|
if !p.walk_clean {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"a walk worker for {} terminated abnormally; \
|
|
|
|
|
skipping stale cleanup",
|
|
|
|
|
p.root
|
|
|
|
|
);
|
|
|
|
|
stale_cleanup_ok = false;
|
|
|
|
|
p.phase = RootPhase::Done;
|
|
|
|
|
} else if *stop_flag.lock().unwrap() {
|
|
|
|
|
p.phase = RootPhase::Done;
|
|
|
|
|
} else {
|
|
|
|
|
let cursor = ExtractCursor::for_root(&p.root);
|
|
|
|
|
let scope =
|
|
|
|
|
extract_scope_prepare(&conn_mutex, &cursor, config)?;
|
|
|
|
|
// Progress counts the root's whole
|
|
|
|
|
// searchable set: files extracted in
|
|
|
|
|
// earlier runs start the counter, so
|
|
|
|
|
// an unchanged root shows "X of X"
|
|
|
|
|
// rather than "0 of 0".
|
|
|
|
|
p.extract_total = scope.pending + scope.already_done;
|
|
|
|
|
p.extracted = scope.already_done;
|
|
|
|
|
if scope.pending == 0 {
|
|
|
|
|
p.phase = RootPhase::Done;
|
|
|
|
|
} else {
|
|
|
|
|
p.extract = Some(cursor);
|
|
|
|
|
p.phase = RootPhase::Extracting;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
progressed = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
progressed |= took > 0;
|
|
|
|
|
}
|
|
|
|
|
RootPhase::Extracting => {
|
|
|
|
|
let cursor = p.extract.as_mut().expect("extracting root has a cursor");
|
|
|
|
|
let mut last_file: Option<String> = None;
|
|
|
|
|
let processed = extract_one_batch(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
cursor,
|
|
|
|
|
®istry,
|
|
|
|
|
config,
|
|
|
|
|
stop_flag,
|
|
|
|
|
suspend_flag,
|
|
|
|
|
&mut |name| last_file = Some(name.to_string()),
|
|
|
|
|
)?;
|
|
|
|
|
if last_file.is_some() {
|
|
|
|
|
p.current_file = last_file;
|
|
|
|
|
}
|
|
|
|
|
if processed == 0 {
|
|
|
|
|
p.extract = None;
|
|
|
|
|
p.phase = RootPhase::Done;
|
|
|
|
|
} else {
|
|
|
|
|
p.extracted += processed;
|
|
|
|
|
}
|
|
|
|
|
progressed = true;
|
|
|
|
|
}
|
|
|
|
|
RootPhase::Done => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rr = rr.wrapping_add(1);
|
|
|
|
|
|
|
|
|
|
// Once every walk has ended, reconcile deletions — globally,
|
|
|
|
|
// because a file may be reachable through more than one root's
|
|
|
|
|
// symlinks. Runs at most once per run, on this writer thread.
|
|
|
|
|
if !cleanup_done && pipelines.iter().all(|p| p.phase != RootPhase::Walking) {
|
|
|
|
|
cleanup_done = true;
|
|
|
|
|
let stopped = *stop_flag.lock().unwrap();
|
|
|
|
|
if stale_cleanup_ok && !stopped {
|
|
|
|
|
let stale_paths: Vec<String> = existing_files
|
|
|
|
|
.keys()
|
|
|
|
|
.filter(|path| !seen_paths.contains(*path))
|
|
|
|
|
.filter(|path| {
|
|
|
|
|
!pipelines.iter().any(|p| p.walk.unreadable().covers(path))
|
|
|
|
|
})
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect();
|
|
|
|
|
let unreadable_count: usize = pipelines
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| p.walk.unreadable().paths().len())
|
|
|
|
|
.sum();
|
|
|
|
|
if unreadable_count > 0 {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"{} director{} could not be read; index entries beneath \
|
|
|
|
|
them were kept rather than deleted",
|
|
|
|
|
unreadable_count,
|
|
|
|
|
if unreadable_count == 1 { "y" } else { "ies" }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if !stale_paths.is_empty() {
|
|
|
|
|
if let Some(first) = pipelines.first_mut() {
|
|
|
|
|
first.current_file =
|
|
|
|
|
Some("Removing stale index entries…".to_string());
|
|
|
|
|
}
|
|
|
|
|
stale_deleted = cleanup_stale_index_entries(
|
|
|
|
|
&conn_mutex,
|
|
|
|
|
stale_paths.as_slice(),
|
|
|
|
|
stop_flag,
|
|
|
|
|
suspend_flag,
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
progressed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publish(&pipelines);
|
|
|
|
|
|
|
|
|
|
if pipelines.iter().all(|p| p.phase == RootPhase::Done) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if !progressed {
|
|
|
|
|
thread::sleep(Duration::from_millis(2));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if aborted {
|
|
|
|
|
// Buffered records are valid work — land them before leaving.
|
|
|
|
|
for p in &mut pipelines {
|
|
|
|
|
process_batch_updates(&conn_mutex, &p.pending_updates, stop_flag, config)?;
|
|
|
|
|
p.pending_updates.clear();
|
|
|
|
|
process_batch_inserts(&conn_mutex, &p.pending_inserts, stop_flag, config)?;
|
|
|
|
|
p.pending_inserts.clear();
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mut status_guard) = status.lock() {
|
|
|
|
|
*status_guard = IndexingStatus::Idle;
|
|
|
|
|
}
|
|
|
|
|
// Deliberately no stale cleanup: a partial walk has a partial
|
|
|
|
|
// seen set, and deleting everything it did not reach would
|
|
|
|
|
// empty most of the index.
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FTS housekeeping once per completed run (cheap if nothing changed).
|
|
|
|
|
let _ = stale_deleted;
|
|
|
|
|
{
|
|
|
|
|
let conn = conn_mutex.lock().unwrap();
|
|
|
|
|
fts_finalize_after_text_indexing(&conn)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stamp the successful run so the coordinator can schedule the next
|
|
|
|
|
// periodic reindex from it.
|
|
|
|
|
let now = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.map(|d| d.as_secs())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
if let Ok(conn) = conn_mutex.lock() {
|
|
|
|
|
let _ = crate::db::repo::set_last_full_index(&conn, now);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The config keys whose change invalidates the stored index, paired
|
|
|
|
|
/// with their current values. One list drives both [`validate_config`]
|
|
|
|
|
/// and [`update_config`] so the two can never drift apart.
|
|
|
|
|
fn config_validation_entries(config: &Config, indexing_path: &str) -> Vec<(&'static str, String)> {
|
|
|
|
|
let sorted_joined = |v: &[String]| {
|
|
|
|
|
let mut v: Vec<String> = v.to_vec();
|
|
|
|
|
v.sort();
|
|
|
|
|
v.join("\n")
|
|
|
|
|
};
|
|
|
|
|
vec![
|
|
|
|
|
("hash_length", config.processing.hash_length.to_string()),
|
|
|
|
|
// Hashes are only comparable within one algorithm, and
|
|
|
|
|
// duplicate detection groups purely by hash. Bump this string
|
|
|
|
|
// whenever the digest input changes so existing indexes are
|
|
|
|
|
// offered a rebuild instead of silently mixing schemes.
|
|
|
|
|
("hash_algorithm", "size+head".to_string()),
|
|
|
|
|
("indexing_path", normalize_root_string(indexing_path)),
|
|
|
|
|
("tokenize", config.processing.tokenize.clone()),
|
|
|
|
|
("include_hidden", config.indexing.include_hidden.to_string()),
|
|
|
|
|
(
|
|
|
|
|
"ignore_patterns",
|
|
|
|
|
sorted_joined(&config.indexing.ignore_patterns),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"content_extensions",
|
|
|
|
|
sorted_joined(&config.indexing.content_extensions),
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compare current config against the values stored in the index.
|
|
|
|
|
/// Returns `Some(changes)` when the index was built under settings
|
|
|
|
|
/// that no longer match — the caller offers a rebuild. A key absent
|
|
|
|
|
/// from the DB (older index) only counts as changed when the DB has
|
|
|
|
|
/// stored *any* validation state before.
|
|
|
|
|
fn validate_config(
|
|
|
|
|
conn: &Connection,
|
|
|
|
|
config: &Config,
|
|
|
|
|
indexing_path: &str,
|
|
|
|
|
) -> Result<Option<Vec<ConfigChange>>, String> {
|
|
|
|
|
let mut changes = Vec::new();
|
|
|
|
|
for (key, current) in Self::config_validation_entries(config, indexing_path) {
|
|
|
|
|
let stored: Option<String> = conn
|
|
|
|
|
.query_row(
|
|
|
|
|
"SELECT value FROM config_validation WHERE key = ?1",
|
|
|
|
|
params![key],
|
|
|
|
|
|r| r.get(0),
|
|
|
|
|
)
|
|
|
|
|
.optional()
|
|
|
|
|
.map_err(|e| format!("read config_validation.{}: {}", key, e))?;
|
|
|
|
|
if let Some(stored) = stored {
|
|
|
|
|
if stored != current {
|
|
|
|
|
changes.push(ConfigChange {
|
|
|
|
|
key: key.to_string(),
|
|
|
|
|
stored,
|
|
|
|
|
current,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(if changes.is_empty() { None } else { Some(changes) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stamp the index with the settings it's being built under.
|
|
|
|
|
fn update_config(conn: &Connection, config: &Config, indexing_path: &str) -> Result<(), String> {
|
|
|
|
|
for (key, current) in Self::config_validation_entries(config, indexing_path) {
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO config_validation (key, value) VALUES (?1, ?2)",
|
|
|
|
|
params![key, current],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| format!("store config_validation.{}: {}", key, e))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Canonicalize a root string for storage/comparison, stripping the Windows
|
|
|
|
|
/// UNC prefix. Multi-root strings (newline-joined) fail canonicalize and
|
|
|
|
|
/// pass through verbatim, which still compares consistently.
|
|
|
|
|
fn normalize_root_string(indexing_path: &str) -> String {
|
|
|
|
|
let path = std::path::Path::new(indexing_path)
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.unwrap_or_else(|_| std::path::PathBuf::from(indexing_path));
|
|
|
|
|
path_to_db_string(&path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for IndexingService {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
// Ensure graceful shutdown when the service is dropped
|
|
|
|
|
let _ = self.stop_indexing();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for IndexingService {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self::new()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|