//! Background search worker: builds and queries the similarity / near-duplicate //! VP-trees off the GUI thread. //! //! Building a VP-tree over the whole library is CPU-bound (the ultra-fuzz audit //! flagged the lazy first-query build freezing the egui frame on large //! libraries). This worker owns both indexes and its own read-only `Database` //! connection (WAL lets it read alongside the main connection), so the build and //! query happen entirely off the render thread. Results return via [`SearchEvent`] //! and are applied in `poll_workers`. //! //! The indexes are cached across queries and dropped on [`SearchCommand::Invalidate`] //! (sent when new analysis lands), so they rebuild from fresh data on the next query. use std::path::PathBuf; use audiofiles_core::db::Database; use audiofiles_core::worker_runtime::{WorkerCtx, WorkerHandle, spawn_worker}; use audiofiles_core::{fingerprint, similarity}; /// Command sent from the GUI thread to the search worker. pub enum SearchCommand { /// Find samples similar to `hash` (builds the similarity index if needed). FindSimilar { hash: String, limit: usize }, /// Find near-duplicates of `hash` by fingerprint (builds the index if needed). FindNearDuplicates { hash: String, limit: usize }, /// Drop both cached indexes so the next query rebuilds from fresh data. Invalidate, } /// Event sent from the search worker back to the GUI thread. pub enum SearchEvent { /// Ranked similar-sample hashes for `source`. SimilarResults { source: String, hashes: Vec }, /// Ranked near-duplicate hashes for `source`. NearDuplicateResults { source: String, hashes: Vec }, /// A search failed (DB open / load / build error). Error { error: String }, } /// Handle for communicating with the background search worker. Search has no /// Cancel command, so the runtime handle is used directly. pub type SearchWorkerHandle = WorkerHandle; /// Persistent worker state: the read-only DB connection and the two cached /// VP-tree indexes, reused across queries and dropped on `Invalidate`. /// /// Eviction policy: each index is bounded by library size (one VP-tree node per /// analyzed sample, order ~100 MB at a million samples), and **at most one is /// resident at a time**. A similarity query evicts the fingerprint index and vice /// versa, so peak index memory is a single tree, not both. The evicted index /// rebuilds on its next query from fresh data, so results are never truncated, /// only the cache is. Combined with `Invalidate` (on new analysis), this is the /// ceiling: ≤ one library-sized tree held, released when the other kind of search /// runs or when analysis lands. struct SearchState { db: Database, similarity_index: Option, fingerprint_index: Option, } /// Spawn the background search worker. It opens its own read-only connection to /// the database at `db_path` on the worker thread. pub fn spawn_search_worker(db_path: PathBuf) -> std::io::Result { spawn_worker( "search-worker", move || { Database::open(&db_path).map(|db| SearchState { db, similarity_index: None, fingerprint_index: None, }) }, |e| SearchEvent::Error { error: e.to_string(), }, |_state| SearchEvent::Error { error: "search worker panicked (internal error)".to_string(), }, search_step, ) } fn search_step(state: &mut SearchState, cmd: SearchCommand, ctx: &WorkerCtx) { match cmd { SearchCommand::Invalidate => { state.similarity_index = None; state.fingerprint_index = None; } SearchCommand::FindSimilar { hash, limit } => { // A malformed feature row that panics is caught by the runtime and // reported as Error; the worker survives for the next query. // Evict the other index: at most one VP-tree resident at a time. state.fingerprint_index = None; let result: Result, audiofiles_core::error::CoreError> = (|| { if state.similarity_index.is_none() { let data = similarity::SimilarityIndex::load_data(&state.db)?; state.similarity_index = Some(similarity::SimilarityIndex::build_from_data(data)); } let built = state.similarity_index.as_ref().expect("set above"); let features = similarity::load_features(&state.db, &hash)?; Ok(built .find_similar(&hash, &features, limit) .into_iter() .map(|r| r.hash) .collect()) })(); ctx.emit(match result { Ok(hashes) => SearchEvent::SimilarResults { source: hash, hashes, }, Err(e) => SearchEvent::Error { error: e.to_string(), }, }); } SearchCommand::FindNearDuplicates { hash, limit } => { // Evict the other index: at most one VP-tree resident at a time. state.similarity_index = None; let result: Result, audiofiles_core::error::CoreError> = (|| { if state.fingerprint_index.is_none() { let entries = fingerprint::FingerprintIndex::load_data(&state.db)?; state.fingerprint_index = Some(fingerprint::FingerprintIndex::build_from_data(entries)); } let built = state.fingerprint_index.as_ref().expect("set above"); let reference = fingerprint::load_fingerprint(&state.db, &hash)?; Ok(built .find_near_duplicates(&hash, &reference.envelope, limit) .into_iter() .map(|r| r.hash) .collect()) })(); ctx.emit(match result { Ok(hashes) => SearchEvent::NearDuplicateResults { source: hash, hashes, }, Err(e) => SearchEvent::Error { error: e.to_string(), }, }); } } } #[cfg(test)] mod tests { use super::*; /// Block (with a generous wall-clock bound) for one event so a regression /// that wedges the worker fails the test instead of hanging the suite. The /// bound is time-based, not iteration-based, so it stays reliable under the /// load of the full parallel test run. fn recv_one(handle: &SearchWorkerHandle) -> SearchEvent { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); while std::time::Instant::now() < deadline { if let Some(ev) = handle.try_recv() { return ev; } std::thread::sleep(std::time::Duration::from_millis(1)); } panic!("search worker produced no event"); } #[test] fn worker_responds_on_empty_db() { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("audiofiles.db"); // Create + migrate the file so the worker opens a valid (empty) schema. let _ = Database::open(&db_path).unwrap(); let handle = spawn_search_worker(db_path).unwrap(); // A query against an empty library must still produce an event (empty // results or a clean error), never a hang or a panic that kills the worker. assert!(handle.send(SearchCommand::FindSimilar { hash: "deadbeef".to_string(), limit: 10, })); match recv_one(&handle) { SearchEvent::SimilarResults { source, hashes } => { assert_eq!(source, "deadbeef"); assert!(hashes.is_empty()); } SearchEvent::Error { .. } => {} // acceptable: no features for the hash SearchEvent::NearDuplicateResults { .. } => panic!("wrong event kind"), } // Invalidate + a second query still respond (worker survived). assert!(handle.send(SearchCommand::Invalidate)); assert!(handle.send(SearchCommand::FindNearDuplicates { hash: "deadbeef".to_string(), limit: 10, })); let _ = recv_one(&handle); drop(handle); // Drop joins the thread; a wedged worker would hang here. } }