diff --git a/Cargo.lock b/Cargo.lock index 2aecf11..cf239ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3152,21 +3152,20 @@ dependencies = [ name = "quicksearch-gui" version = "1.1.7" dependencies = [ - "ashpd", "chrono", "eframe", "egui", "egui_extras", - "futures-channel", - "futures-util", "global-hotkey", "keyring", "open", - "pollster", "quicksearch-core", "raw-window-handle", "rfd", "rpassword", + "wayland-backend", + "wayland-client", + "wayland-protocols", "windows-sys 0.59.0", "x11rb", "zeroize", diff --git a/README.md b/README.md index b0735e8..1834fff 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ tracking: https://github.com/DataScienceDIY/quick_search - **Password protection** — optionally encrypt the index, since it contains the names and text of everything indexed. The password can be remembered in your system keychain. -- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere - while QuickSearch is running, and `quicksearch --toggle` can be bound in - your desktop's keyboard settings to also start it. +- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere, + starting QuickSearch if it is closed. One click on the Settings tab binds + it on KDE and GNOME; elsewhere, bind `quicksearch --toggle` in your + desktop's keyboard settings. - **Terminal search** — `quicksearch ` prints ranked, pipe-friendly paths; `--long` adds sizes, dates, and highlighted snippets. - **Portable mode** — keep the program, its config, and its index together diff --git a/config_example.toml b/config_example.toml index ba9cb47..a7e8cd6 100644 --- a/config_example.toml +++ b/config_example.toml @@ -94,8 +94,6 @@ batch_size = 500 # leave another root's walkers parked behind it. 0 gives each turn one # batch_size quantum and no more. Clamped to 0..10000 on load. writer_turn_slice_ms = 100 -# Files per transaction for incremental FTS updates. -fts_update_batch_size = 1000 # How large the write-ahead log (index.sqlite-wal) may grow during an # indexing run before the indexer forces a checkpoint (bytes); left # alone, the log grows for the whole run. Not a safety knob: on a volume @@ -150,8 +148,9 @@ watch_cap_warned_roots = [] # The shortcut QuickSearch claims for itself while it is running: brings it to # the front, switches to the Search tab and selects whatever is in the search # box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it -# empty ("") for no shortcut. To start QuickSearch when it is closed, bind -# "quicksearch --toggle" in your desktop's keyboard settings. +# empty ("") for no shortcut. To start QuickSearch when it is closed, use +# "Set up system shortcut" on the Settings tab, or bind "quicksearch +# --toggle" in your desktop's keyboard settings yourself. search_hotkey = "Ctrl+Shift+F" # 'dark' or 'light'; anything other than 'light' is dark. Applied as soon # as it is changed on the Settings tab. Following the desktop's own @@ -172,7 +171,10 @@ show_advanced_settings = false tutorial_seen = false [search] -# Start with the fuzzy passes enabled. +# Whether the Fuzzy box on the Search tab starts ticked. Off by default: the +# fuzzy passes cost noticeable time on every keystroke, and most searches do +# not need them. Tick it in the GUI whenever you want typo tolerance for a +# session, or set it here to start that way every time. fuzzy_default = false # Ceiling on the fuzzy stages' typo budget. The allowance grows with the # search term, one edit per three characters, up to this value, so 2 diff --git a/crates/quicksearch-core/benches/index.rs b/crates/quicksearch-core/benches/index.rs index 582a134..e886eeb 100644 --- a/crates/quicksearch-core/benches/index.rs +++ b/crates/quicksearch-core/benches/index.rs @@ -136,3 +136,103 @@ mod mime_sniff { } } + +/// `repo::update_file_basic`'s state narrowing: an NA→NA update — the whole +/// of a re-run over a text-free tree — pays one statement instead of four. +/// The pair: `clear_always` reimplements the pre-narrowing shape (the control +/// that must not move between builds); `narrowed` is the shipped function. +/// NA→NA is idempotent, so iterations are stable without re-seeding. +/// DONE→PENDING is deliberately not benched: its statement count is identical +/// in both shapes (clearing that must happen either way), so there is nothing +/// to regress. +mod update_narrowing { + use super::*; + use quicksearch_core::db::repo::{self, NewFile}; + use quicksearch_core::mime::FileType; + use quicksearch_core::testutil::Scratch; + use rusqlite::{params, Connection, OptionalExtension}; + + const ROWS: usize = 1000; + + fn new_file(name: &str) -> NewFile<'_> { + NewFile { + name, + parent: "/b/", + size: 7, + mtime: 7, + mime: None, + ftype: FileType::EMPTY, + hash: None, + needs_content: false, + } + } + + fn seeded_na(tag: &str) -> (Scratch, Connection) { + let (dir, p) = Scratch::db(tag); + let mut conn = + quicksearch_core::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + let tx = conn.transaction().unwrap(); + let names: Vec = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect(); + for name in &names { + repo::insert_file(&tx, &new_file(name)).unwrap().unwrap(); + } + tx.commit().unwrap(); + (dir, conn) + } + + #[divan::bench] + fn clear_always(bencher: Bencher) { + let (_dir, mut conn) = seeded_na("bench-update-old"); + let names: Vec = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect(); + bencher.bench_local(move || { + let tx = conn.transaction().unwrap(); + for name in &names { + let f = new_file(name); + let id: i64 = tx + .prepare_cached( + "UPDATE files + SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5, + content_state = ?6 + WHERE parent = ?7 AND name = ?8 + RETURNING id", + ) + .unwrap() + .query_row( + params![ + f.size as i64, + f.mtime as i64, + f.hash, + f.mime, + f.ftype.bits() as i64, + repo::STATE_NA, + f.parent, + f.name, + ], + |r| r.get(0), + ) + .optional() + .unwrap() + .unwrap(); + repo::remove_content_for_id(&tx, id).unwrap(); + tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1") + .unwrap() + .execute([id]) + .unwrap(); + } + tx.commit().unwrap(); + }); + } + + #[divan::bench] + fn narrowed(bencher: Bencher) { + let (_dir, mut conn) = seeded_na("bench-update-new"); + let names: Vec = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect(); + bencher.bench_local(move || { + let tx = conn.transaction().unwrap(); + for name in &names { + repo::update_file_basic(&tx, &new_file(name)).unwrap().unwrap(); + } + tx.commit().unwrap(); + }); + } +} diff --git a/crates/quicksearch-core/benches/search_alloc.rs b/crates/quicksearch-core/benches/search_alloc.rs index 8ce77b9..ad1ec3b 100644 --- a/crates/quicksearch-core/benches/search_alloc.rs +++ b/crates/quicksearch-core/benches/search_alloc.rs @@ -254,6 +254,16 @@ const CASES: &[Case] = &[ fuzzy: false, passes: "regex name + content, no prefilter possible", }, + Case { + // The accept-predicate: a common term beside a regex whose path + // check misses everything, so every pass-A candidate fetches and + // decodes its stored body (or discovers its absence) through + // `Cx::regex_accepts` — per row, the shape `DocDecoder` exists for. + label: "term + regex", + query: r"content regex:zzznever\d", + fuzzy: false, + passes: "A hits many, regex accept-predicate decodes per candidate", + }, Case { label: "common (capped)", query: "content", diff --git a/crates/quicksearch-core/benches/search_perf.rs b/crates/quicksearch-core/benches/search_perf.rs index b3e8030..4fd8408 100644 --- a/crates/quicksearch-core/benches/search_perf.rs +++ b/crates/quicksearch-core/benches/search_perf.rs @@ -12,9 +12,10 @@ //! What has to stay resident is the **`files` table**, not the FTS index. //! `search/cascade/passes.rs` answers filename queries (ranks 1–4, 9–10) with //! `SELECT … FROM files f WHERE f.name LIKE '%…%'` — a full table scan, no FTS -//! at all — and the fuzzy pass scans it again with `WHERE 1=1`. So the working -//! set scales with **file count**, not with document volume, and the corpus -//! dimension below is what makes that visible. +//! at all — and the fuzzy pass scans it again (`LIKE`-narrowed for terms of +//! 9+ characters, unpredicated below that). So the working set scales with +//! **file count**, not with document volume, and the corpus dimension below +//! is what makes that visible. //! //! The keyed rows are where an undersized cache hurts first: a page-cache miss //! costs an AES-CBC decrypt, not a `memcpy`. @@ -155,20 +156,63 @@ fn mib(bytes: u64) -> f64 { /// Run one query; hits are counted, not kept — holding 200k `SearchHit`s /// would measure the allocator instead of the scan. -fn time_query(conn: &Connection, query: &str) -> (Duration, usize) { +fn time_query(conn: &Connection, query: &str, options: &SearchOptions) -> (Duration, usize) { let split = split_for_cascade(query).unwrap(); let latest = std::sync::atomic::AtomicU64::new(1); let mut count = 0usize; let mut sink = |hits: Vec| count += hits.len(); - let options = SearchOptions { - limit: 1000, - ..SearchOptions::default() - }; let start = Instant::now(); - cascade::run(conn, &split, &options, 1, &latest, &mut sink).unwrap(); + cascade::run(conn, &split, options, 1, &latest, &mut sink).unwrap(); (start.elapsed(), count) } +/// The options every measurement here runs under: the sweep's, with `fuzzy` +/// the variable. Both `SearchOptions::default()` and the GUI default +/// (`[search] fuzzy_default`) have fuzzy **off** — the fuzzy rows below are +/// what a user opts into, not what a default install pays per keystroke. +fn options(fuzzy: bool) -> SearchOptions { + SearchOptions { + limit: 1000, + fuzzy, + ..SearchOptions::default() + } +} + +/// The fuzzy pairing's keystroke sequence, per-term rather than averaged: +/// under the default `fuzzy_max_edits` of 2, `pigeonhole_chunks` needs +/// `3 × (k + 1)` characters, so terms under 9 characters keep the full scan +/// (`edit_budget` gives k=1 at 3–5 and k=2 from 6) and only the 9+ ones can +/// be prefiltered. Both shapes are in the sequence on purpose: the short +/// terms are the control a pass-C narrowing must not move, the long ones are +/// what it narrows. `quartzite` is `testutil::NEEDLE`, present in seeded +/// names, so the engaged rows return real hits. +const FUZZY_SEQUENCE: [&str; 4] = ["quar", "quartz", "quartzite", "quartzites"]; + +/// Warm per-term timings, fuzzy off against on, at the shipped cache — the +/// gate for the fuzzy filename pass's shape (`passes::pass_fuzzy_filename`). +/// The off column is the control: a pass-C change has no business moving it. +fn run_fuzzy_pairing(arm: &Arm) { + let conn = arm.open_search(); + conn.execute_batch(&format!("PRAGMA cache_size = {};", SHIPPED_CACHE)) + .unwrap(); + // One settling pass per mode so every printed number is warm. + for fuzzy in [false, true] { + let opts = options(fuzzy); + for query in FUZZY_SEQUENCE { + time_query(&conn, query, &opts); + } + } + println!( + "{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)", + "term", "off", "on", "hits" + ); + for query in FUZZY_SEQUENCE { + let (off, _) = time_query(&conn, query, &options(false)); + let (on, hits) = time_query(&conn, query, &options(true)); + println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits); + } +} + /// One arm at one cache ceiling: the first keystroke on a fresh connection, /// then the steady state after a priming pass. /// @@ -182,13 +226,17 @@ fn measure(arm: &Arm, cache_size: i64) -> (Duration, Duration, usize) { conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size)) .unwrap(); - let (cold, hits) = time_query(&conn, SEQUENCE[0]); + let opts = options(false); + let (cold, hits) = time_query(&conn, SEQUENCE[0], &opts); // A priming pass, so "warm" is a settled session rather than the three // keystrokes after the first. for query in SEQUENCE { - time_query(&conn, query); + time_query(&conn, query, &opts); } - let total: Duration = SEQUENCE.iter().map(|q| time_query(&conn, q).0).sum(); + let total: Duration = SEQUENCE + .iter() + .map(|q| time_query(&conn, q, &opts).0) + .sum(); (cold, total / SEQUENCE.len() as u32, hits) } @@ -302,7 +350,21 @@ fn main() { std::env::temp_dir().display() ); } - for files in CORPORA { + // `QSB_SEARCH_PERF=fuzzy` runs only the fuzzy pairing — the cache sweep + // is minutes per arm and a pass-C gate does not need it. `QSB_CORPORA` + // trims the corpus list the same way (comma-separated file counts). + let fuzzy_only = std::env::var("QSB_SEARCH_PERF").as_deref() == Ok("fuzzy"); + let corpora: Vec = std::env::var("QSB_CORPORA") + .ok() + .map(|v| { + v.split(',') + .filter_map(|n| n.trim().parse().ok()) + .collect() + }) + .filter(|v: &Vec| !v.is_empty()) + .unwrap_or_else(|| CORPORA.to_vec()); + + for files in corpora { for keyed in [false, true] { let arm = Arm::seed( format!( @@ -312,10 +374,31 @@ fn main() { ), &format!("searchperf-{}-{}", files, keyed), keyed, - &spec(files), + &if fuzzy_only { + light_spec(files) + } else { + spec(files) + }, ); - run_matrix(&arm, files); + if fuzzy_only { + println!("\n=== {} === seeded in {:.1?}", arm.what, arm.seeded_in); + } else { + run_matrix(&arm, files); + } + run_fuzzy_pairing(&arm); arm.discard(); } } } + +/// The fuzzy-only corpus: `files` rows and nothing else. Pass C never reads a +/// document — its whole cost is the `files` scan plus the bitap — and 2 KB +/// bodies turn a minutes-long seed into hours on slow storage while adding +/// only pass-D noise to the number under test. One row in `usize::MAX` still +/// gets content, which keeps pass D exercised without costing anything. +fn light_spec(files: usize) -> SeedSpec { + SeedSpec { + content_every: usize::MAX, + ..spec(files) + } +} diff --git a/crates/quicksearch-core/examples/pruneprobe.rs b/crates/quicksearch-core/examples/pruneprobe.rs index 2aa23e2..245b3cb 100644 --- a/crates/quicksearch-core/examples/pruneprobe.rs +++ b/crates/quicksearch-core/examples/pruneprobe.rs @@ -51,18 +51,28 @@ //! Every stage runs against a byte-identical copy of one seeded index rather //! than a fresh seed: FTS5 segment layout is most of what decides delete cost, //! and reseeding would let it drift between the rows of the table. +//! +//! The final table prices the *other* bulk withdrawal, a completed run's stale +//! cleanup (`file_handling::cleanup_stale_index_entries`): the same doomed +//! rows, deleted by path the way that pass does. Its stages are spelled out in +//! probe code for the same reason as above — they are the fixed decomposition +//! — and its `live` row is the shipped function, which is the row that moves +//! when `file_handling::batch` is reshaped. mod common; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension}; use quicksearch_core::config::Config; use quicksearch_core::db::{self, repo}; use quicksearch_core::extract::Registry; -use quicksearch_core::file_handling::ExtractCursor; +use quicksearch_core::file_handling::{ + cleanup_stale_index_entries, fts_begin_tombstone_burst, fts_end_tombstone_burst, + fts_finalize_after_text_indexing, split_db_path, ExtractCursor, +}; use quicksearch_core::scope::{self, Scope, WorkCursor}; use quicksearch_core::testutil::{self, Arm, SeedSpec}; @@ -397,6 +407,165 @@ fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Durati (removed, fts, at.elapsed()) } +// --------------------------------------------------------------------------- +// Stale cleanup stages +// --------------------------------------------------------------------------- + +/// Cumulative slices of a completed run's stale cleanup, which deletes by +/// *path* rather than deciding rows from a page it already read. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum StaleShape { + /// Per-path deletes, three statements each, FTS handed every id — the + /// pass as originally shipped. This is the control row: it reproduces + /// that shape in probe code, so it must not move when + /// `cleanup_stale_index_entries` is reshaped. + PerRow, + /// ...resolve `(id, content_state)` per path instead, then chunked + /// `IN (...)` deletes with FTS narrowed to ids that can hold a posting. + Ids, + /// ...and hold FTS5's delete-merging off for the whole pass. + Burst, +} + +impl StaleShape { + const ALL: [StaleShape; 3] = [StaleShape::PerRow, StaleShape::Ids, StaleShape::Burst]; + + fn label(self) -> &'static str { + match self { + StaleShape::PerRow => "per-row", + StaleShape::Ids => "+ids", + StaleShape::Burst => "+burst", + } + } + + fn tag(self) -> &'static str { + match self { + StaleShape::PerRow => "row", + StaleShape::Ids => "ids", + StaleShape::Burst => "burst", + } + } +} + +/// Delete `paths` the way stale cleanup does, one transaction per `PAGE` +/// chunk, timing each phase. Column mapping in the shared table: `cover` is +/// the id resolution, `files` the `files` deletes, `fts` the tombstones plus +/// the trailing consolidation. +fn run_stale(conn: &Connection, paths: &[String], shape: StaleShape) -> Timing { + let mut t = Timing::default(); + let io_before = Io::read(); + let (_, misses_before) = testutil::cache_stats(conn); + let started = Instant::now(); + + if shape == StaleShape::Burst { + fts_begin_tombstone_burst(conn); + } + for page in paths.chunks(PAGE as usize) { + let tx = conn.unchecked_transaction().expect("begin"); + if shape == StaleShape::PerRow { + for path in page { + let Some((parent, name)) = split_db_path(path) else { + continue; + }; + let at = Instant::now(); + let id: Option = tx + .prepare_cached( + "DELETE FROM files WHERE parent = ?1 AND name = ?2 RETURNING id", + ) + .expect("prepare") + .query_row(rusqlite::params![parent, name], |r| r.get(0)) + .optional() + .expect("delete row"); + t.files += at.elapsed(); + let Some(id) = id else { continue }; + t.deleted += 1; + let at = Instant::now(); + tx.prepare_cached("DELETE FROM searchabletext WHERE rowid = ?1") + .expect("prepare") + .execute([id]) + .expect("tombstone"); + t.fts += at.elapsed(); + let at = Instant::now(); + tx.prepare_cached("DELETE FROM documents_text WHERE file_id = ?1") + .expect("prepare") + .execute([id]) + .expect("clear body"); + t.files += at.elapsed(); + } + } else { + let at = Instant::now(); + let mut ids: Vec = Vec::new(); + let mut with_postings: Vec = Vec::new(); + { + let mut sel = tx + .prepare_cached( + "SELECT id, content_state FROM files WHERE parent = ?1 AND name = ?2", + ) + .expect("prepare"); + for path in page { + let Some((parent, name)) = split_db_path(path) else { + continue; + }; + let row: Option<(i64, i64)> = sel + .query_row(rusqlite::params![parent, name], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .optional() + .expect("resolve"); + if let Some((id, state)) = row { + ids.push(id); + if state == repo::STATE_DONE { + with_postings.push(id); + } + } + } + } + t.cover += at.elapsed(); + for chunk in with_postings.chunks(CHUNK) { + let at = Instant::now(); + tx.execute( + &format!( + "DELETE FROM searchabletext WHERE rowid IN ({})", + placeholders(chunk.len()) + ), + rusqlite::params_from_iter(chunk.iter()), + ) + .expect("tombstone"); + t.fts += at.elapsed(); + } + for chunk in ids.chunks(CHUNK) { + let at = Instant::now(); + t.deleted += tx + .execute( + &format!( + "DELETE FROM files WHERE id IN ({})", + placeholders(chunk.len()) + ), + rusqlite::params_from_iter(chunk.iter()), + ) + .expect("delete rows"); + t.files += at.elapsed(); + } + } + let at = Instant::now(); + tx.commit().expect("commit"); + t.commit += at.elapsed(); + } + let at = Instant::now(); + if shape == StaleShape::Burst { + fts_end_tombstone_burst(conn); + } else { + fts_finalize_after_text_indexing(conn); + } + t.fts += at.elapsed(); + + t.total = started.elapsed(); + t.io = Io::read().since(&io_before); + let (_, misses_after) = testutil::cache_stats(conn); + t.misses = misses_after - misses_before; + t +} + /// How much data FTS5 is holding — the only quiescence signal that works. /// /// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge @@ -875,6 +1044,91 @@ fn main() { arm.discard(); } + // Stale cleanup, the pass a completed run ends with. Same doomed rows + // as the tables above, but deleted by *path* — the walk hands + // `cleanup_stale_index_entries` a list of paths it did not see, in + // directory order. Runs on `PRAGMAS_FAST` via `open_existing(_, true)` + // because that is the run's own writer connection, the one the real + // pass executes on — `open` here would measure the reconcile's 4 MiB + // cache instead. + { + let stale: Vec = { + let conn = open(&master); + let mut stmt = conn + .prepare( + "SELECT parent || name FROM files WHERE parent LIKE ?1 \ + ORDER BY parent, name", + ) + .expect("prepare"); + let rows = stmt + .query_map([format!("%/{}/%", PRUNE_PATTERN)], |r| r.get(0)) + .expect("query stale paths"); + rows.collect::, _>>().expect("read stale paths") + }; + println!("\n stale cleanup, {} doomed paths:", stale.len()); + header(); + for shape in StaleShape::ALL { + let arm = clone_arm(&master, &format!("prune-{}-stale-{}", label, shape.tag())); + let conn = arm.with_key(|| { + db::open::open_existing(&arm.path.to_string_lossy(), true) + .expect("open the copy") + }); + let t = run_stale(&conn, &stale, shape); + row(shape.label(), &t); + let (took, rounds, before, after) = merge_to_quiescence(&conn); + println!( + " {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows", + "", + took.as_secs_f64() * 1000.0, + rounds, + before, + after + ); + drop(conn); + arm.discard(); + } + + // The shipped function, whole. The row that moves when + // `file_handling::batch` is reshaped; the stages above must not. + let arm = clone_arm(&master, &format!("prune-{}-stale-live", label)); + let conn = arm.with_key(|| { + db::open::open_existing(&arm.path.to_string_lossy(), true).expect("open the copy") + }); + let (_, misses_before) = testutil::cache_stats(&conn); + let conn_mutex = std::sync::Arc::new(std::sync::Mutex::new(conn)); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = Instant::now(); + let deleted = cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &config) + .expect("cleanup stale"); + let total = started.elapsed(); + let conn = std::sync::Arc::try_unwrap(conn_mutex) + .map_err(|_| ()) + .expect("sole owner") + .into_inner() + .expect("unpoisoned"); + let (_, misses_after) = testutil::cache_stats(&conn); + row( + "live", + &Timing { + total, + deleted, + misses: misses_after - misses_before, + ..Timing::default() + }, + ); + let (took, rounds, before, after) = merge_to_quiescence(&conn); + println!( + " {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows <- cleanup_stale_index_entries", + "", + took.as_secs_f64() * 1000.0, + rounds, + before, + after + ); + drop(conn); + arm.discard(); + } + master.discard(); } } diff --git a/crates/quicksearch-core/src/config/mod.rs b/crates/quicksearch-core/src/config/mod.rs index 4d01a65..56835ed 100644 --- a/crates/quicksearch-core/src/config/mod.rs +++ b/crates/quicksearch-core/src/config/mod.rs @@ -84,7 +84,6 @@ pub struct ProcessingConfig { /// milliseconds — the bound on how long any one root can hold up the /// others. `0` gives each turn one `batch_size` quantum and no more. pub writer_turn_slice_ms: u64, - pub fts_update_batch_size: usize, /// How large the WAL may grow during a run before the indexer forces a /// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`]. /// Needed because autocheckpoint can only *reset* the log when no reader @@ -240,7 +239,6 @@ impl Default for ProcessingConfig { maximum_text_file_size: 1024 * 1024 * 2, batch_size: 500, writer_turn_slice_ms: 100, - fts_update_batch_size: 1000, maximum_wal_size: 1024 * 1024 * 1024 * 2, tokenize: "trigram".to_string(), store_text_for_snippets: true, @@ -251,7 +249,7 @@ impl Default for ProcessingConfig { impl Default for SearchConfig { fn default() -> Self { SearchConfig { - fuzzy_default: true, + fuzzy_default: false, fuzzy_max_edits: 2, display_limit: 1000, results_per_page: 100, diff --git a/crates/quicksearch-core/src/db/repo.rs b/crates/quicksearch-core/src/db/repo.rs index b21fa64..d4d1f51 100644 --- a/crates/quicksearch-core/src/db/repo.rs +++ b/crates/quicksearch-core/src/db/repo.rs @@ -103,14 +103,26 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 { /// Update a file's metadata in place and reset its content state, clearing /// any extracted content. `None` if no row matches. +/// +/// The clearing is narrowed by the row's *stored* state, the way +/// `scope::PagePlan` narrows: content rows exist exactly for `STATE_DONE` +/// (`repo_tests::leaving_done_always_takes_the_posting_with_it`) and +/// `failed_files` rows exactly for `STATE_FAILED`, so every other state has +/// nothing to clear and pays one statement instead of four — most changed +/// rows on a text-free tree are NA→NA, measured 1.24x on that shape +/// (`benches/index.rs`, group `update_narrowing`; DONE→PENDING is unchanged +/// by construction, the clearing happens either way). +/// `content_state` is deliberately **not +/// in the SET list**: `RETURNING` reports the post-update row, so assigning +/// it there would return the new state and the narrowing would read its own +/// write. pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result, String> { - let id: Option = tx + let row: Option<(i64, i64)> = tx .prepare_cached( "UPDATE files - SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5, - content_state = ?6 - WHERE parent = ?7 AND name = ?8 - RETURNING id", + SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5 + WHERE parent = ?6 AND name = ?7 + RETURNING id, content_state", ) .and_then(|mut stmt| { stmt.query_row( @@ -120,27 +132,42 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result