quick_search/crates/quicksearch-core/src/search/mod.rs

356 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Interruptible, streaming search service.
//!
//! One dedicated worker thread owns the cascade. The GUI (or any caller)
//! sends queries via [`SearchService::search`]; results stream back over
//! an mpsc receiver as [`SearchUpdate`] events tagged with a generation
//! number. Starting a new search bumps the generation and interrupts the
//! in-flight SQLite statement, so a keystroke never waits on the previous
//! query.
//!
//! Cancellation is two-layer:
//! - **cooperative** — the cascade compares its generation against the
//! latest every few hundred rows and stops silently when stale;
//! - **interrupt** — [`rusqlite::InterruptHandle::interrupt`] kills the
//! statement currently executing (covering the "no rows produced yet"
//! phases like FTS candidate gathering). An interrupted stale search is
//! normal cancellation, not an error.
//!
//! Consumers that want a plain blocking search (the CLI mode) skip the
//! service entirely and call [`cascade::run`] with a collecting sink.
pub mod cascade;
pub mod duplicates;
pub mod fuzzy;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread::JoinHandle;
use crate::db;
use crate::query::split::split_for_cascade;
use crate::snippet::Snippet;
pub use cascade::Outcome;
pub use duplicates::{find_duplicate_groups, DuplicateGroup};
/// One search result. `rank` is the sort key (lower = better): integer
/// part = cascade stage (111), fraction = occurrence-count or
/// edit-distance tiebreak. Batches arrive already rank-ordered and later
/// batches only append, so a rank-sorted view never reshuffles.
#[derive(Debug, Clone)]
pub struct SearchHit {
pub file_id: i64,
pub name: String,
pub path: String,
pub size: u64,
pub mtime: i64,
pub rank: f64,
pub stage: u8,
/// The matched span in context: the filename for name stages, the full
/// path for path stages, a window of the body for full-text stages
/// (absent there when document text isn't stored).
pub snippet: Option<Snippet>,
}
#[derive(Debug, Clone)]
pub enum SearchUpdate {
Started {
generation: u64,
},
Hits {
generation: u64,
hits: Vec<SearchHit>,
},
Completed {
generation: u64,
total: usize,
limited: bool,
},
Error {
generation: u64,
message: String,
},
}
impl SearchUpdate {
pub fn generation(&self) -> u64 {
match self {
SearchUpdate::Started { generation }
| SearchUpdate::Hits { generation, .. }
| SearchUpdate::Completed { generation, .. }
| SearchUpdate::Error { generation, .. } => *generation,
}
}
}
#[derive(Debug, Clone)]
pub struct SearchOptions {
/// Enable the fuzzy stages (ranks 7, 8 and 11).
pub fuzzy: bool,
/// Ceiling on the fuzzy edit budget (`[search].fuzzy_max_edits`); see
/// [`fuzzy::edit_budget`]. 0 disables the fuzzy stages.
pub fuzzy_max_edits: usize,
/// Hard cap on total hits per search (`[search].display_limit`).
pub limit: usize,
/// Streaming batch size (`[search].results_per_page`).
pub batch: usize,
/// Session-scoped ignore patterns (GUI chips), same glob semantics as
/// the config's `ignore_patterns`. Applied before the display cap.
pub session_ignores: Vec<String>,
}
impl Default for SearchOptions {
fn default() -> Self {
SearchOptions {
fuzzy: false,
fuzzy_max_edits: 2,
limit: 1000,
batch: 100,
session_ignores: Vec::new(),
}
}
}
struct SearchRequest {
generation: u64,
input: String,
options: SearchOptions,
}
pub struct SearchService {
req_tx: mpsc::Sender<SearchRequest>,
latest_gen: Arc<AtomicU64>,
interrupt: Arc<Mutex<Option<rusqlite::InterruptHandle>>>,
db_path: Arc<Mutex<PathBuf>>,
handle: Option<JoinHandle<()>>,
}
impl SearchService {
/// Spawn the worker. `notify` is invoked after every update event so
/// an egui frontend can `request_repaint` (pass a no-op for headless
/// use). Returns the service handle plus the update receiver, which
/// the caller drains non-blockingly.
pub fn new(
db_path: PathBuf,
notify: Arc<dyn Fn() + Send + Sync>,
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
let (req_tx, req_rx) = mpsc::channel::<SearchRequest>();
let (update_tx, update_rx) = mpsc::channel::<SearchUpdate>();
let latest_gen = Arc::new(AtomicU64::new(0));
let interrupt = Arc::new(Mutex::new(None));
let db_path = Arc::new(Mutex::new(db_path));
let worker = Worker {
req_rx,
update_tx,
notify,
latest_gen: latest_gen.clone(),
interrupt: interrupt.clone(),
db_path: db_path.clone(),
};
let handle = std::thread::Builder::new()
.name("qs-search".into())
.spawn(move || worker.run())
.expect("spawn search worker");
(
SearchService {
req_tx,
latest_gen,
interrupt,
db_path,
handle: Some(handle),
},
update_rx,
)
}
/// Start a new search, cancelling any in-flight one. Returns the
/// generation whose events to keep.
pub fn search(&self, input: &str, options: SearchOptions) -> u64 {
let generation = self.latest_gen.fetch_add(1, Ordering::SeqCst) + 1;
let _ = self.req_tx.send(SearchRequest {
generation,
input: input.to_string(),
options,
});
// The new request can't be running yet (the worker hasn't dequeued
// it), so this only ever kills a stale generation's statement.
self.interrupt_current();
generation
}
/// Cancel without starting anything new.
pub fn cancel(&self) {
self.latest_gen.fetch_add(1, Ordering::SeqCst);
self.interrupt_current();
}
/// Point subsequent searches at a different index file.
pub fn set_db_path(&self, path: PathBuf) {
*self.db_path.lock().unwrap() = path;
self.cancel();
}
fn interrupt_current(&self) {
if let Ok(guard) = self.interrupt.lock() {
if let Some(handle) = guard.as_ref() {
handle.interrupt();
}
}
}
/// Cancel, close the request channel, and join the worker.
pub fn shutdown(self) {
self.cancel();
let SearchService { req_tx, handle, .. } = self;
drop(req_tx);
if let Some(handle) = handle {
let _ = handle.join();
}
}
}
/// Map SQLite-level errors to the tagged strings frontends key off.
/// `DATABASE_CORRUPTED:` drives the GUI's recovery dialog.
pub fn classify_sql_err(error_msg: &str) -> String {
if error_msg.starts_with(db::KEY_MISMATCH_PREFIX) {
// Wrong or missing encryption key. Already user-legible, and it
// must never fall into the corruption bucket — the recovery dialog
// would offer to delete an index that is perfectly intact.
error_msg.to_string()
} else if error_msg.contains("malformed")
|| error_msg.contains("corrupt")
|| error_msg.contains("database disk image is malformed")
{
format!("DATABASE_CORRUPTED: {}", error_msg)
} else if error_msg.contains("fts5: syntax error") {
"Search syntax error: the search term contains characters that cannot be processed."
.to_string()
} else {
format!("Search failed: {}", error_msg)
}
}
struct Worker {
req_rx: mpsc::Receiver<SearchRequest>,
update_tx: mpsc::Sender<SearchUpdate>,
notify: Arc<dyn Fn() + Send + Sync>,
latest_gen: Arc<AtomicU64>,
interrupt: Arc<Mutex<Option<rusqlite::InterruptHandle>>>,
db_path: Arc<Mutex<PathBuf>>,
}
impl Worker {
fn run(self) {
while let Ok(first) = self.req_rx.recv() {
// A fast typist queues several requests; only the newest one
// matters.
let mut req = first;
while let Ok(newer) = self.req_rx.try_recv() {
req = newer;
}
if req.generation != self.latest_gen.load(Ordering::SeqCst) {
continue;
}
self.handle(req);
}
}
fn send(&self, update: SearchUpdate) {
let _ = self.update_tx.send(update);
(self.notify)();
}
fn handle(&self, req: SearchRequest) {
let generation = req.generation;
self.send(SearchUpdate::Started { generation });
let split = match split_for_cascade(&req.input) {
Ok(s) => s,
Err(e) => {
self.send(SearchUpdate::Error {
generation,
message: e.to_string(),
});
return;
}
};
let db_path = self.db_path.lock().unwrap().clone();
// Per-request open: microseconds, and always sees a freshly
// rebuilt index file rather than pinning a deleted inode.
let conn = match db::open_existing(&db_path.to_string_lossy(), false) {
Ok(c) => c,
Err(e) => {
self.send(SearchUpdate::Error {
generation,
message: classify_sql_err(&e),
});
return;
}
};
*self.interrupt.lock().unwrap() = Some(conn.get_interrupt_handle());
let mut sink = |hits: Vec<SearchHit>| {
self.send(SearchUpdate::Hits { generation, hits });
};
let outcome = cascade::run(
&conn,
&split,
&req.options,
generation,
&self.latest_gen,
&mut sink,
);
*self.interrupt.lock().unwrap() = None;
match outcome {
Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed {
generation,
total,
limited,
}),
// Cancelled — the newer generation owns the UI now.
Ok(None) => {}
Err(e) => self.send(SearchUpdate::Error {
generation,
message: classify_sql_err(&e),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_mismatch_is_never_classified_as_corruption() {
let msg = format!(
"{}index at /tmp/x.sqlite: wrong password (or the file is not a QuickSearch index)",
db::KEY_MISMATCH_PREFIX
);
let classified = classify_sql_err(&msg);
assert_eq!(classified, msg, "must pass through verbatim");
assert!(!classified.starts_with("DATABASE_CORRUPTED:"));
// The raw SQLite wording for an undecryptable page must not land in
// the corruption bucket either — the GUI recovery dialog offers to
// delete the file, which is exactly wrong for an intact encrypted
// index. (It doesn't match the corruption needles; pin that.)
let raw = "Failed to read database at /tmp/x.sqlite: file is not a database";
assert!(!classify_sql_err(raw).starts_with("DATABASE_CORRUPTED:"));
}
#[test]
fn corruption_and_syntax_classification_still_work() {
assert!(
classify_sql_err("database disk image is malformed").starts_with("DATABASE_CORRUPTED:")
);
assert!(classify_sql_err("fts5: syntax error near \"NEAR\"").starts_with("Search syntax"));
assert!(classify_sql_err("no such table: files").starts_with("Search failed:"));
}
}