2026-08-02 19:04:30 -04:00
|
|
|
|
//! 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.
|
|
|
|
|
|
//!
|
2026-08-04 23:33:28 -04:00
|
|
|
|
//! The interrupt handle is stored tagged with the generation that owns it,
|
|
|
|
|
|
//! and only ever fired at a generation the counter has already moved past.
|
|
|
|
|
|
//! Interrupting the *current* generation would surface to the user as
|
|
|
|
|
|
//! "Search failed: interrupted" instead of results, and the window for it
|
|
|
|
|
|
//! is real: the worker can dequeue and start a request before the caller
|
|
|
|
|
|
//! that queued it gets back onto the CPU.
|
|
|
|
|
|
//!
|
2026-08-02 19:04:30 -04:00
|
|
|
|
//! Consumers that want a plain blocking search (the CLI mode) skip the
|
|
|
|
|
|
//! service entirely and call [`cascade::run`] with a collecting sink.
|
2026-08-05 19:17:11 -04:00
|
|
|
|
//!
|
|
|
|
|
|
//! # The worker's connection
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! The worker holds one connection across requests and releases it after
|
|
|
|
|
|
//! [`IDLE_RELEASE`] of quiet. This is not an optimisation of open cost — an
|
|
|
|
|
|
//! open is microseconds — but of the page cache behind it: a search runs on
|
|
|
|
|
|
//! every character typed, so reopening per request meant paying to warm a
|
|
|
|
|
|
//! cache and then discarding it, once per keystroke, forever.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! What the old per-request open bought, and what now has to be arranged
|
|
|
|
|
|
//! deliberately, is never operating on an index that has been replaced
|
|
|
|
|
|
//! underneath it. Two things can do that, and only one is visible in the path:
|
|
|
|
|
|
//! the config can point at a different file, and a rebuild, clear or
|
|
|
|
|
|
//! schema-drift wipe can put a *new* file at the *same* path. The second is
|
|
|
|
|
|
//! why [`crate::db::index_epoch`] exists — see [`Worker::take_connection`].
|
|
|
|
|
|
//! Holding a handle on a replaced index would mean stale results and, on
|
|
|
|
|
|
//! Linux, its blocks staying allocated until the handle closed.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
pub mod cascade;
|
|
|
|
|
|
pub mod duplicates;
|
|
|
|
|
|
pub mod fuzzy;
|
|
|
|
|
|
|
2026-08-05 19:17:11 -04:00
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
|
use std::sync::{mpsc, Arc, Mutex};
|
|
|
|
|
|
use std::thread::JoinHandle;
|
2026-08-05 19:17:11 -04:00
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
|
|
use rusqlite::Connection;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
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};
|
|
|
|
|
|
|
2026-08-05 19:17:11 -04:00
|
|
|
|
/// How long the worker keeps its connection after the last request.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The connection is held across requests so a typing session runs against a
|
|
|
|
|
|
/// warm page cache (see [`Worker::take_connection`]). It is not held *forever*, for
|
|
|
|
|
|
/// two reasons that have nothing to do with the cache: an open handle on a
|
|
|
|
|
|
/// deleted index keeps its blocks allocated on disk, and an open reader stops
|
|
|
|
|
|
/// SQLite from resetting the WAL, which would then grow toward
|
|
|
|
|
|
/// `maximum_wal_size` and never come back down.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// So: long enough to span the pauses inside a search session, short enough
|
|
|
|
|
|
/// that an abandoned one is not still holding the index minutes later.
|
|
|
|
|
|
const IDLE_RELEASE: Duration = Duration::from_secs(30);
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
/// One search result. `rank` is the sort key (lower = better): integer
|
|
|
|
|
|
/// part = cascade stage (1–11), 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 {
|
2026-08-04 03:27:05 -04:00
|
|
|
|
Started {
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
},
|
|
|
|
|
|
Hits {
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
hits: Vec<SearchHit>,
|
|
|
|
|
|
},
|
|
|
|
|
|
Completed {
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
total: usize,
|
|
|
|
|
|
limited: bool,
|
|
|
|
|
|
},
|
|
|
|
|
|
Error {
|
|
|
|
|
|
generation: u64,
|
|
|
|
|
|
message: String,
|
|
|
|
|
|
},
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 23:33:28 -04:00
|
|
|
|
/// The search the worker is executing right now, and the handle that kills
|
|
|
|
|
|
/// its statement. Tagged with the generation so a caller can tell whether
|
|
|
|
|
|
/// the thing it is about to interrupt is the search it means to cancel or
|
|
|
|
|
|
/// one that has since replaced it.
|
|
|
|
|
|
type InFlight = Arc<Mutex<Option<(u64, rusqlite::InterruptHandle)>>>;
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
pub struct SearchService {
|
|
|
|
|
|
req_tx: mpsc::Sender<SearchRequest>,
|
|
|
|
|
|
latest_gen: Arc<AtomicU64>,
|
2026-08-04 23:33:28 -04:00
|
|
|
|
in_flight: InFlight,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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>,
|
2026-08-05 19:17:11 -04:00
|
|
|
|
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
|
|
|
|
|
|
Self::new_with_idle_release(db_path, notify, IDLE_RELEASE)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// [`Self::new`] with an explicit connection-release window.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Tests use short windows; the default [`IDLE_RELEASE`] is right for real
|
|
|
|
|
|
/// use, where the window has to span the pauses inside a typing session.
|
|
|
|
|
|
pub fn new_with_idle_release(
|
|
|
|
|
|
db_path: PathBuf,
|
|
|
|
|
|
notify: Arc<dyn Fn() + Send + Sync>,
|
|
|
|
|
|
idle_release: Duration,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
) -> (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));
|
2026-08-04 23:33:28 -04:00
|
|
|
|
let in_flight: InFlight = Arc::new(Mutex::new(None));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let db_path = Arc::new(Mutex::new(db_path));
|
|
|
|
|
|
|
|
|
|
|
|
let worker = Worker {
|
|
|
|
|
|
req_rx,
|
|
|
|
|
|
update_tx,
|
|
|
|
|
|
notify,
|
|
|
|
|
|
latest_gen: latest_gen.clone(),
|
2026-08-04 23:33:28 -04:00
|
|
|
|
in_flight: in_flight.clone(),
|
2026-08-02 19:04:30 -04:00
|
|
|
|
db_path: db_path.clone(),
|
2026-08-05 19:17:11 -04:00
|
|
|
|
open: None,
|
|
|
|
|
|
idle_release,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
};
|
|
|
|
|
|
let handle = std::thread::Builder::new()
|
|
|
|
|
|
.name("qs-search".into())
|
|
|
|
|
|
.spawn(move || worker.run())
|
|
|
|
|
|
.expect("spawn search worker");
|
|
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
SearchService {
|
|
|
|
|
|
req_tx,
|
|
|
|
|
|
latest_gen,
|
2026-08-04 23:33:28 -04:00
|
|
|
|
in_flight,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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;
|
2026-08-04 23:33:28 -04:00
|
|
|
|
// Interrupt before enqueueing: an idle worker can dequeue the new
|
|
|
|
|
|
// request and be mid-statement within microseconds, and there is no
|
|
|
|
|
|
// point handing it a kill the worker has to survive.
|
|
|
|
|
|
self.interrupt_stale();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let _ = self.req_tx.send(SearchRequest {
|
|
|
|
|
|
generation,
|
|
|
|
|
|
input: input.to_string(),
|
|
|
|
|
|
options,
|
|
|
|
|
|
});
|
|
|
|
|
|
generation
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Cancel without starting anything new.
|
|
|
|
|
|
pub fn cancel(&self) {
|
|
|
|
|
|
self.latest_gen.fetch_add(1, Ordering::SeqCst);
|
2026-08-04 23:33:28 -04:00
|
|
|
|
self.interrupt_stale();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Point subsequent searches at a different index file.
|
|
|
|
|
|
pub fn set_db_path(&self, path: PathBuf) {
|
|
|
|
|
|
*self.db_path.lock().unwrap() = path;
|
|
|
|
|
|
self.cancel();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 23:33:28 -04:00
|
|
|
|
/// Kill the running statement — but only if the generation counter has
|
|
|
|
|
|
/// already moved past the search that owns it.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Callers bump `latest_gen` first, so anything still tagged with an
|
|
|
|
|
|
/// older generation is stale by definition. The tag is what makes this
|
|
|
|
|
|
/// safe rather than merely well-timed: interrupting the *newest* search
|
|
|
|
|
|
/// does not cancel anything, it fails it, and the cascade reports that
|
|
|
|
|
|
/// as `Search failed: interrupted` because its own generation is still
|
|
|
|
|
|
/// current. On a loaded machine the worker really can pick up and start
|
|
|
|
|
|
/// a request before the thread that queued it runs again, so "the new
|
|
|
|
|
|
/// search cannot have started yet" is not an assumption to build on.
|
|
|
|
|
|
fn interrupt_stale(&self) {
|
|
|
|
|
|
let latest = self.latest_gen.load(Ordering::SeqCst);
|
|
|
|
|
|
if let Ok(guard) = self.in_flight.lock() {
|
|
|
|
|
|
if let Some((generation, handle)) = guard.as_ref() {
|
|
|
|
|
|
if *generation != latest {
|
|
|
|
|
|
handle.interrupt();
|
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2026-08-02 20:21:19 -04:00
|
|
|
|
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")
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|| 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>,
|
2026-08-04 23:33:28 -04:00
|
|
|
|
in_flight: InFlight,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
db_path: Arc<Mutex<PathBuf>>,
|
2026-08-05 19:17:11 -04:00
|
|
|
|
/// The connection, and the index generation and path it was opened
|
|
|
|
|
|
/// against. See [`Worker::take_connection`].
|
|
|
|
|
|
open: Option<OpenIndex>,
|
|
|
|
|
|
/// How long `open` survives with no requests; [`IDLE_RELEASE`] outside
|
|
|
|
|
|
/// tests.
|
|
|
|
|
|
idle_release: Duration,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A connection held across requests, tagged with what it was opened on.
|
|
|
|
|
|
struct OpenIndex {
|
|
|
|
|
|
conn: Connection,
|
|
|
|
|
|
epoch: u64,
|
|
|
|
|
|
path: PathBuf,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Worker {
|
2026-08-05 19:17:11 -04:00
|
|
|
|
fn run(mut self) {
|
|
|
|
|
|
loop {
|
|
|
|
|
|
// `recv_timeout`, not `recv`: a search session is a burst of
|
|
|
|
|
|
// requests one keystroke apart, and the connection is worth
|
|
|
|
|
|
// keeping for the length of one. Past that it is worth strictly
|
|
|
|
|
|
// less than nothing — see [`IDLE_RELEASE`].
|
|
|
|
|
|
let first = match self.req_rx.recv_timeout(self.idle_release) {
|
|
|
|
|
|
Ok(req) => req,
|
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
|
|
|
|
self.open = None;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// The service was dropped; nothing more is coming.
|
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
|
|
|
|
|
};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 19:17:11 -04:00
|
|
|
|
/// Take the connection to run this request on, reopening if the one held
|
|
|
|
|
|
/// cannot be reused.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Taken out and handed back by the caller, rather than borrowed from
|
|
|
|
|
|
/// `self`, for the same reason [`crate::coordinator`]'s writer is: the
|
|
|
|
|
|
/// cascade needs the connection for its whole run, and everything else on
|
|
|
|
|
|
/// `self` — the update channel, the generation counter, the interrupt slot
|
|
|
|
|
|
/// — has to stay reachable while it does.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Reuse is what makes [`crate::db::schema::PRAGMAS_SEARCH`]'s page cache
|
|
|
|
|
|
/// worth having: searches run on every character typed, so the second
|
|
|
|
|
|
/// query of a session and every one after it finds the b-tree interior
|
|
|
|
|
|
/// pages and FTS5 segment tips already resident. Opening per request threw
|
|
|
|
|
|
/// that away each time.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// It is reopened when either half of what it was opened against has
|
|
|
|
|
|
/// changed. The path moves when the user points the config at a different
|
|
|
|
|
|
/// index. The epoch moves when the file at the *same* path is replaced —
|
|
|
|
|
|
/// a rebuild, a clear, or a schema-drift wipe — which no comparison of
|
|
|
|
|
|
/// paths could ever catch, and which would otherwise leave this worker
|
|
|
|
|
|
/// querying a deleted inode and pinning its blocks on disk.
|
|
|
|
|
|
fn take_connection(&mut self, db_path: &Path) -> Result<OpenIndex, String> {
|
|
|
|
|
|
let epoch = db::index_epoch();
|
|
|
|
|
|
if let Some(open) = self.open.take() {
|
|
|
|
|
|
if open.epoch == epoch && open.path == db_path {
|
|
|
|
|
|
return Ok(open);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Dropped here, before the open below, so the handle on the old
|
|
|
|
|
|
// index is gone before a handle on the new one exists.
|
|
|
|
|
|
drop(open);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(OpenIndex {
|
|
|
|
|
|
conn: db::open::open_search_reader(&db_path.to_string_lossy())?,
|
|
|
|
|
|
epoch,
|
|
|
|
|
|
path: db_path.to_path_buf(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
fn send(&self, update: SearchUpdate) {
|
|
|
|
|
|
let _ = self.update_tx.send(update);
|
|
|
|
|
|
(self.notify)();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 19:17:11 -04:00
|
|
|
|
fn handle(&mut self, req: SearchRequest) {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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();
|
2026-08-05 19:17:11 -04:00
|
|
|
|
let open = match self.take_connection(&db_path) {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
Ok(c) => c,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
self.send(SearchUpdate::Error {
|
|
|
|
|
|
generation,
|
|
|
|
|
|
message: classify_sql_err(&e),
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-08-04 23:33:28 -04:00
|
|
|
|
// Publish the handle tagged with the generation it kills, before the
|
|
|
|
|
|
// first statement runs. Anyone cancelling from here on can tell this
|
|
|
|
|
|
// search apart from the one that supersedes it.
|
2026-08-05 19:17:11 -04:00
|
|
|
|
*self.in_flight.lock().unwrap() = Some((generation, open.conn.get_interrupt_handle()));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
let mut sink = |hits: Vec<SearchHit>| {
|
|
|
|
|
|
self.send(SearchUpdate::Hits { generation, hits });
|
|
|
|
|
|
};
|
|
|
|
|
|
let outcome = cascade::run(
|
2026-08-05 19:17:11 -04:00
|
|
|
|
&open.conn,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
&split,
|
|
|
|
|
|
&req.options,
|
|
|
|
|
|
generation,
|
|
|
|
|
|
&self.latest_gen,
|
|
|
|
|
|
&mut sink,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-04 23:33:28 -04:00
|
|
|
|
*self.in_flight.lock().unwrap() = None;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
2026-08-05 19:17:11 -04:00
|
|
|
|
// Kept only if it still works. A failed cascade may have failed
|
|
|
|
|
|
// *because* of this connection — a torn file, or an interrupt that
|
|
|
|
|
|
// left it unusable — and putting it back would wedge every search
|
|
|
|
|
|
// after this one behind the same bad handle. Dropping it costs one
|
|
|
|
|
|
// reopen.
|
|
|
|
|
|
if outcome.is_ok() {
|
|
|
|
|
|
self.open = Some(open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
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),
|
|
|
|
|
|
}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
2026-08-04 23:33:28 -04:00
|
|
|
|
/// Start a query long enough to be killed while it is executing, on its
|
|
|
|
|
|
/// own thread. Returns the handle that kills it and the result channel.
|
|
|
|
|
|
fn spawn_slow_query() -> (
|
|
|
|
|
|
rusqlite::InterruptHandle,
|
|
|
|
|
|
mpsc::Receiver<rusqlite::Result<i64>>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
|
|
|
|
let handle = conn.get_interrupt_handle();
|
|
|
|
|
|
let (tx, rx) = mpsc::channel();
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let counted = conn.query_row(
|
|
|
|
|
|
"WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 1000000) \
|
|
|
|
|
|
SELECT count(*) FROM c",
|
|
|
|
|
|
[],
|
|
|
|
|
|
|row| row.get::<_, i64>(0),
|
|
|
|
|
|
);
|
|
|
|
|
|
let _ = tx.send(counted);
|
|
|
|
|
|
});
|
|
|
|
|
|
(handle, rx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Cancel repeatedly for as long as the query runs, so every window in
|
|
|
|
|
|
/// which an interrupt could land is exercised rather than hoped past.
|
|
|
|
|
|
fn cancel_until_done(
|
|
|
|
|
|
service: &SearchService,
|
|
|
|
|
|
rx: &mpsc::Receiver<rusqlite::Result<i64>>,
|
|
|
|
|
|
) -> rusqlite::Result<i64> {
|
|
|
|
|
|
loop {
|
|
|
|
|
|
match rx.recv_timeout(std::time::Duration::from_millis(1)) {
|
|
|
|
|
|
Ok(result) => return result,
|
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Timeout) => service.interrupt_stale(),
|
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => panic!("query thread died"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn idle_service() -> SearchService {
|
|
|
|
|
|
// Nothing is ever enqueued, so the path is never opened.
|
|
|
|
|
|
SearchService::new(PathBuf::from("/nonexistent"), Arc::new(|| {})).0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Typing the next character must not kill the search that character
|
|
|
|
|
|
/// started. The worker can be mid-statement on the newest generation by
|
|
|
|
|
|
/// the time the caller gets around to cancelling — on a slow machine that
|
|
|
|
|
|
/// is common, not exotic — and killing it there surfaces as
|
|
|
|
|
|
/// "Search failed: interrupted" instead of results.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn cancelling_spares_the_newest_generation() {
|
|
|
|
|
|
let service = idle_service();
|
|
|
|
|
|
let (handle, rx) = spawn_slow_query();
|
|
|
|
|
|
service.latest_gen.store(7, Ordering::SeqCst);
|
|
|
|
|
|
*service.in_flight.lock().unwrap() = Some((7, handle));
|
|
|
|
|
|
|
|
|
|
|
|
let result = cancel_until_done(&service, &rx);
|
|
|
|
|
|
*service.in_flight.lock().unwrap() = None;
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
result.ok(),
|
|
|
|
|
|
Some(1_000_000),
|
|
|
|
|
|
"the newest generation was interrupted"
|
|
|
|
|
|
);
|
|
|
|
|
|
service.shutdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The other half: a generation the counter has moved past still dies
|
|
|
|
|
|
/// promptly, which is what keeps a keystroke from waiting on the previous
|
|
|
|
|
|
/// query.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn cancelling_kills_a_superseded_generation() {
|
|
|
|
|
|
let service = idle_service();
|
|
|
|
|
|
let (handle, rx) = spawn_slow_query();
|
|
|
|
|
|
service.latest_gen.store(8, Ordering::SeqCst);
|
|
|
|
|
|
*service.in_flight.lock().unwrap() = Some((7, handle));
|
|
|
|
|
|
|
|
|
|
|
|
let err = cancel_until_done(&service, &rx)
|
|
|
|
|
|
.expect_err("a superseded generation must be interrupted");
|
|
|
|
|
|
*service.in_flight.lock().unwrap() = None;
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
err.sqlite_error_code(),
|
|
|
|
|
|
Some(rusqlite::ErrorCode::OperationInterrupted),
|
|
|
|
|
|
"unexpected error: {}",
|
|
|
|
|
|
err
|
|
|
|
|
|
);
|
|
|
|
|
|
service.shutdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
|
#[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() {
|
2026-08-04 03:27:05 -04:00
|
|
|
|
assert!(
|
|
|
|
|
|
classify_sql_err("database disk image is malformed").starts_with("DATABASE_CORRUPTED:")
|
|
|
|
|
|
);
|
2026-08-02 20:21:19 -04:00
|
|
|
|
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:"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|