Significant performance pass. Reduced memory churn massively. ~1.5-3x search performance improvements. Minor improvements to warm indexing time.
Some checks failed
CI / linux (push) Successful in 3m7s
CI / windows-cross (push) Failing after 1m12s
CI / release (push) Has been skipped

This commit is contained in:
= 2026-08-09 18:36:47 -04:00
parent 5714b5f969
commit 5f55d19849
30 changed files with 1991 additions and 122 deletions

85
Cargo.lock generated
View file

@ -111,6 +111,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arboard"
version = "3.6.1"
@ -658,6 +664,32 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstyle",
"clap_lex",
"terminal_size",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@ -697,6 +729,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "condtype"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -890,6 +928,31 @@ dependencies = [
"syn 2.0.66",
]
[[package]]
name = "divan"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933"
dependencies = [
"cfg-if",
"clap",
"condtype",
"divan-macros",
"libc",
"regex-lite",
]
[[package]]
name = "divan-macros"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "dlib"
version = "0.5.3"
@ -3125,12 +3188,13 @@ dependencies = [
[[package]]
name = "quicksearch-core"
version = "1.0.5"
version = "1.0.6"
dependencies = [
"argon2",
"cfb",
"chardetng",
"ctrlc",
"divan",
"encoding_rs",
"getrandom 0.2.15",
"globset",
@ -3138,6 +3202,7 @@ dependencies = [
"kamadak-exif",
"libc",
"lofty",
"memchr",
"mime_guess",
"notify",
"pdf-extract",
@ -3157,7 +3222,7 @@ dependencies = [
[[package]]
name = "quicksearch-gui"
version = "1.0.5"
version = "1.0.6"
dependencies = [
"ashpd",
"chrono",
@ -3323,6 +3388,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.4"
@ -3824,6 +3895,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "terminal_size"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
"windows-sys 0.61.0",
]
[[package]]
name = "thiserror"
version = "1.0.61"

View file

@ -5,8 +5,27 @@ members = [
"crates/quicksearch-gui",
]
# No `[profile.release]` on purpose. Cross-crate inlining looks like it should
# pay here — the cascade calls into `snippet`/`query`, both into `memchr` and
# `zstd`, everything into `rusqlite`'s FFI wrappers — so it was measured, and
# it does not. Against an incremental rebuild of the GUI after touching core
# (10.2 s at the defaults):
#
# lto=thin 67 s search cold 29.9 ms warm best 14.77 ms index cold 459 ms
# lto=thin,cgu=1 152 s (build cost alone ruled it out)
# lto=fat,cgu=1 200 s search cold 29.2 ms warm best 14.57 ms index cold 479 ms
# (defaults) 10 s search cold 30.0 ms warm best 14.92 ms index cold 478 ms
#
# Every runtime column moves 0-4% and index-cold is not even monotonic, so the
# gain is at the noise floor while the build is 6.6-19.5x longer — on a CI that
# builds three targets. Re-measure before concluding otherwise.
#
# `panic = "abort"` is separately unavailable: `extract/pdf.rs` runs
# `pdf-extract` inside `catch_unwind`, so aborting would turn a malformed PDF
# into a killed process instead of one skipped file.
[workspace.package]
version = "1.0.5"
version = "1.0.6"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -237,7 +237,13 @@ inside that folder.
restarts until you return to automatic. The index size beside the
status heading totals the database and its `-wal`/`-shm` sidecars,
refreshed every ten seconds; hovering it lists the ways to make it
smaller.
smaller. Each folder in the list carries what it holds — files
indexed, and how many of those had text extracted — counted once as
each indexing run finishes and stored with the index, so they are
there the moment the app opens rather than costing a scan to show. A
folder nothing has finished indexing reads "not yet indexed" rather
than zero, and because the figures come from completed runs they do
not move as live updates apply single changes in between.
- **Duplicates**: files sharing a content hash, grouped.
- **Logs**: the lines the app would have printed to a terminal — warnings
from indexing, folder watching and opening files, newest last, with a
@ -458,7 +464,14 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
then held — the coordinator's writer, before it learned to let go when idle —
keeps that memory for the life of the process. Search is the one deliberately
large one, because it is the only cache reused often enough to pay for
itself, and it is released once searching stops.
itself, and it is released once searching stops. Dropping the connection is
only half of releasing it: glibc hands the freed pages back to its own arena
rather than to the kernel, so the search worker calls
`platform::release_free_heap` after it lets go, exactly as
`coordinator::go_idle` does for the writer. Without that call one typing
session left the process 42 MiB heavier for as long as it ran — measured on a
77k-file index, where an idle GUI sat at 76 MiB `RssAnon` and stayed there,
against 34 MiB before the first search and 42 MiB once the trim runs.
- **Indexing** (`indexing.rs`, `file_handling.rs`): full runs walk each
root (`filtered_walk` prunes hidden/ignored subtrees before descending),
classify files by mtime into insert/update/skip, batch-write metadata,
@ -467,9 +480,15 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
OLE2 streams are read in `extract/ole.rs` — PDF, audio tags, EXIF; see
`extract/`) for FTS. PDFs are parsed once, with the text and the `Info`
dictionary taken off the same document: the two-parse version that preceded
it was a run's largest single memory consumer, and it was what pulled a
second copy of `lopdf` — and with it rayon's never-torn-down thread pool —
into the build. Files whose extension no MIME
it was the largest single memory consumer of a run over a PDF-heavy tree, and
it was what pulled a second copy of `lopdf` — and with it rayon's
never-torn-down thread pool — into the build. That is a claim about PDFs
rather than about runs in general, and it is worth knowing which tree a
number came from: on one with almost no PDFs, a cold run peaks at 130 MiB
against 27 MiB for the same walk with content extraction switched off, and
switching off `store_text_for_snippets` moves that peak not at all — so what
is left is the extraction workers and FTS5's own index build, not any single
parser and not the stored text. Files whose extension no MIME
table knows — including extensionless ones like `README` or `Makefile`
are sniffed from their head bytes and indexed as text only when that head
is provably text: valid UTF-8, or BOM-marked (`mime.rs`, `textenc.rs`).
@ -554,6 +573,31 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
flush last, so weaker matches only ever append. All SQL is
parameterized; structured filters from the query language (`query/`)
are ANDed onto every pass.
The passes that read document text share one `zstd::bulk::Decompressor` and
one output buffer per scan (`DocDecoder` in `search/cascade/passes.rs`),
and the row's path is borrowed from the statement rather than copied — only
rows that become hits own one. Both matter more than they look: peak memory
during a search never exceeded 14 MiB even before any of this, but a *single*
fuzzy query moved 31 GiB through `malloc`, and resident-set sampling is blind
to that by construction, because a buffer allocated and freed inside one loop
iteration never moves RSS. `DocDecoder` must therefore never fall back to a
per-row allocating decode, and there is a trap waiting there:
`zstd::encode_all`, which the indexer writes with, is *stream*-based and so
records no content size in the frame header, meaning
`get_frame_content_size` returns `None` for every row this ever sees. The
"cannot happen" branch is the only branch. Sizing the buffer from the header
and handing the `None` case to `zstd::decode_all` looks obviously right and
costs ~2.4 MiB per document, because `decode_all` builds a streaming decoder
per call — 27 of the 30 GiB a fuzzy search moved. Growing this buffer and
keeping it is what makes decoding a row allocate nothing at all.
Measured over the same 77k-file index, per query:
`cascade` 582 → 14 MiB, `function` 6.0 GiB → 29 MiB, `--fuzzy cascade`
31 GiB → 57 MiB, `regex:` 31 GiB → 39 MiB, each a little faster rather than
slower. `TermPattern::find_first` folds nothing either: its
case-insensitive literal branch used to allocate a lowercased copy of its
haystack, which the filename pass asked for twice per row of a full-table
scan.
- **Baloo compatibility** (`cli.rs`, `mime.rs`): the read API this repo's
parent consumes — `status_for_path`, `list_failed`,
`index_size_breakdown`, `pending_content_count`, `clear_path` — plus a
@ -612,6 +656,15 @@ microseconds regardless of row count.
synthesizing clicks and reading back the painted text (`test_ui.rs`) — over
the search and manage tabs, the options editor, the unlock gate, the logs
and duplicates tabs, and query highlighting.
- `cargo bench -p quicksearch-core --bench search` and `--bench index`: divan
microbenchmarks over the two hot paths. Each group runs *what the code does
today* against *the change being considered*, in one process on one corpus,
so the comparison is a measurement rather than an estimate — read the losing
arm as documentation of something already tried. Sizes sweep 1 KiB, 16 KiB
and 256 KiB, the last being `maximum_text_size` and so the worst a full-text
row can present; `benches/corpus/` builds all of it from a fixed seed. This
is the harness to extend when a hot path is in question, because it is the
only one here that can A/B a single function.
- `QSB_SNIPPET_PERF=1 cargo test --release -p quicksearch-core --test
snippet_perf -- --nocapture`: snippet pipeline benchmark.
- `QSB_SEARCH_PERF=1 cargo test --release -p quicksearch-core --test
@ -632,6 +685,16 @@ microseconds regardless of row count.
distinguishable from live data. It reads another process's `/proc`, so it
measures a build made without knowing it would be measured.
`indexprobe` and `walkprobe` answer "how fast" rather than "how much".
All of these read the resident set, and none of them can see allocator
*churn*: a buffer allocated and freed within one loop iteration never moves
RSS, so a search whose peak is a flat 14 MiB can still be pushing tens of
gigabytes a query through `malloc`. Both search-side regressions found so far
were invisible to every probe listed above and showed up only under an
interposed `malloc` that counted calls and bytes. Until a probe here reports
allocation counts, measure that separately before concluding a path is cheap,
and measure the GUI rather than a one-shot `quicksearch-cli` run — a
short-lived process cannot show what a typing session retains.
- `.forgejo/workflows/ci.yml`: builds both platforms on every push to `master`
and every pull request. To cut a release, bump `[workspace.package] version`
in `Cargo.toml` and push the commit on a branch named `Release...`; CI runs

View file

@ -62,6 +62,13 @@ ctrlc = "3.4"
zstd = "0.13"
globset = "0.4"
regex = "1"
# SIMD substring search for the full-text passes. `str::match_indices` uses
# std's Two-Way searcher, which has no vector prefilter: measured against a
# 256 KiB body (`benches/search.rs`, group `substring`) it runs 111 µs where
# `memmem` runs 2.4 µs, and the full-text passes scan a body per candidate
# row. Already in the lockfile transitively (regex, globset, chardetng), so
# naming it directly compiles nothing new.
memchr = "2"
# `nice()`, for dropping indexing threads to background scheduling priority.
# Linux schedules per task, so it affects only the calling thread. Already in
@ -91,3 +98,21 @@ windows-sys = { version = "0.52", features = [
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
] }
# Microbenchmark harness for `benches/`. Criterion is the more common choice
# and was rejected: it pulls rayon (a global thread pool that never tears
# down, the same reason `lopdf` is not named directly above) plus plotters and
# clap, some forty crates. Divan's mandatory set is a handful and it spawns no
# threads of its own.
[dev-dependencies]
divan = "0.1"
# `harness = false` on both: divan supplies its own `main` via `divan::main()`,
# so libtest must not also link one in.
[[bench]]
name = "search"
harness = false
[[bench]]
name = "index"
harness = false

View file

@ -0,0 +1,188 @@
//! Shared fixtures for the `search` and `index` benchmarks.
//!
//! Everything here is deterministic — the same LCG `tests/search_perf.rs` and
//! `examples/indexprobe.rs` use, seeded identically. A fixed corpus is what
//! makes two runs comparable, so a number that moved is a real change rather
//! than a different document.
//!
//! Corpora are built once per process and shared by every benchmark that
//! wants them. Divan re-runs a benchmarked closure thousands of times; paying
//! 256 KiB of text generation inside that loop would measure the generator.
// Both bench binaries compile the whole module but each uses only part of it.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::LazyLock;
/// Deterministic pseudo-random word picker.
pub struct Lcg(u64);
impl Lcg {
pub fn new(seed: u64) -> Lcg {
Lcg(seed)
}
pub fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
self.0 >> 33
}
}
/// Filler vocabulary. Deliberately excludes [`NEEDLE`] and every prefix of it
/// past two characters, so the only occurrences in a document are the ones
/// planted on purpose and a "zero hits" corpus really has zero.
const WORDS: &[&str] = &[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa",
"lambda", "sigma", "report", "summary", "meeting", "invoice", "contract", "budget", "revenue",
"quarter", "planning", "review", "draft", "final", "notes", "appendix", "figure",
];
/// The term every search benchmark looks for. Nine bytes, no overlap with
/// [`WORDS`], and long enough to clear the trigram floor the full-text pass
/// applies.
pub const NEEDLE: &str = "quartzite";
/// The three document sizes every size-swept benchmark uses, in bytes.
///
/// 1 KiB is a short note, 16 KiB a typical source file or README, and 256 KiB
/// is `maximum_text_size` — the ceiling `config.processing` lets into the
/// index, and so the worst case a full-text pass has to survive per row.
pub const SIZES: [usize; 3] = [1 << 10, 16 << 10, 256 << 10];
/// Occurrence counts to sweep. Zero is the important one: a full-text pass
/// verifies far more candidate rows than it accepts, because the trigram
/// index matches on character triples rather than the whole term.
pub const HITS: [usize; 3] = [0, 4, 64];
/// Word text of about `size` bytes with [`NEEDLE`] planted `hits` times at
/// even spacing.
///
/// Even spacing matters: it means a count has to scan to the end of the
/// document, and a snippet window never contains every match. Both are what
/// the cascade actually does.
pub fn document(size: usize, hits: usize) -> String {
let mut lcg = Lcg::new(0x5eed);
let mut out = String::with_capacity(size + 16);
let stride = if hits == 0 { usize::MAX } else { size / hits };
let mut next_plant = stride;
while out.len() < size {
if out.len() >= next_plant {
out.push_str(NEEDLE);
out.push(' ');
next_plant = next_plant.saturating_add(stride);
continue;
}
out.push_str(WORDS[lcg.next() as usize % WORDS.len()]);
out.push(' ');
}
out
}
/// [`document`], but with the planted term capitalised so a case-sensitive
/// scan misses and only the folded one hits.
///
/// This is the stage-6 row and the tier-4 row — the case the cascade pays a
/// fold for, and the one a corpus of all-lowercase text would never produce.
pub fn document_mixed_case(size: usize, hits: usize) -> String {
let mut needle = NEEDLE.to_string();
needle.replace_range(0..1, &NEEDLE[0..1].to_uppercase());
document(size, hits).replace(NEEDLE, &needle)
}
type Corpus = HashMap<(usize, usize), String>;
fn build(f: fn(usize, usize) -> String) -> Corpus {
let mut m = HashMap::new();
for size in SIZES {
for hits in HITS {
m.insert((size, hits), f(size, hits));
}
}
m
}
static LOWER: LazyLock<Corpus> = LazyLock::new(|| build(document));
static MIXED: LazyLock<Corpus> = LazyLock::new(|| build(document_mixed_case));
static FOLDED: LazyLock<Corpus> = LazyLock::new(|| {
MIXED
.iter()
.map(|(k, v)| (*k, v.to_ascii_lowercase()))
.collect()
});
static BLOBS: LazyLock<HashMap<(usize, usize), Vec<u8>>> = LazyLock::new(|| {
LOWER
.iter()
.map(|(k, v)| (*k, zstd::encode_all(v.as_bytes(), 3).expect("encode")))
.collect()
});
/// An all-lowercase document: a case-sensitive scan finds every planted term.
pub fn text(size: usize, hits: usize) -> &'static str {
&LOWER[&(size, hits)]
}
/// A document whose planted terms are capitalised: case-sensitive misses,
/// folded hits.
pub fn text_mixed(size: usize, hits: usize) -> &'static str {
&MIXED[&(size, hits)]
}
/// [`text_mixed`] pre-folded, for the benchmarks that measure a search
/// against a haystack the caller already lowered.
pub fn text_folded(size: usize, hits: usize) -> &'static str {
&FOLDED[&(size, hits)]
}
/// [`text`] as stored: zstd level 3, exactly what `db/repo.rs` writes.
pub fn blob(size: usize, hits: usize) -> &'static [u8] {
&BLOBS[&(size, hits)]
}
/// Realistic file names and full paths, for the benchmarks that model the
/// filename pass rather than the full-text one.
///
/// The name pass scans the whole `files` table, so what matters is the
/// per-row cost on a *miss* — most rows match the SQL `LIKE` on some
/// directory component and then fail every name tier.
pub struct Row {
pub name: String,
pub path: String,
}
static ROWS: LazyLock<Vec<Row>> = LazyLock::new(|| {
let mut lcg = Lcg::new(0xd00d);
(0..2000)
.map(|i| {
let w1 = WORDS[lcg.next() as usize % WORDS.len()];
let w2 = WORDS[lcg.next() as usize % WORDS.len()];
let name = format!("{}-{}-{:05}.txt", w1, w2, i);
// Mixed case in the directory portion, so the folded tiers are
// the ones that resolve — the common shape on real trees.
let path = format!("/home/user/Documents/Quartzite/{:03}/{}", i % 40, name);
Row { name, path }
})
.collect()
});
pub fn rows() -> &'static [Row] {
&ROWS
}
/// The head of a plain-text file, as the walk reads it: `hash_length`
/// (8 KiB) bytes or the whole file, whichever is smaller.
pub fn text_head() -> &'static [u8] {
static HEAD: LazyLock<Vec<u8>> =
LazyLock::new(|| document(8 << 10, 2).into_bytes()[..8 << 10].to_vec());
&HEAD
}
/// A binary head that no extractor claims — the control group for the MIME
/// sniff, which has to reject it by scanning.
pub fn binary_head() -> &'static [u8] {
static HEAD: LazyLock<Vec<u8>> = LazyLock::new(|| {
let mut lcg = Lcg::new(0xbeef);
(0..8 << 10).map(|_| (lcg.next() & 0xff) as u8).collect()
});
&HEAD
}

View file

@ -0,0 +1,190 @@
//! Indexing-path microbenchmarks.
//!
//! Same convention as `benches/search.rs`: each group pairs two ways of doing
//! the same work, measured together, so a choice is justified rather than
//! asserted — and a losing arm records something already tried.
//!
//! ```text
//! cargo bench -p quicksearch-core --bench index
//! ```
mod corpus;
use divan::Bencher;
use quicksearch_core::{mime, textenc, walk};
fn main() {
divan::main();
}
/// Compressing an extracted document, which `repo::set_content_done` used to
/// do itself — on the single writer thread, inside the transaction, and so
/// inside the `conn_mutex` hold `store_extracted` takes across a whole chunk.
///
/// `encode_all` allocates and tears down a fresh `ZSTD_CCtx` — window, hash
/// and chain tables — per document, and for the small documents that dominate
/// a real tree that setup costs more than the compression. `batch_*` below
/// measures a whole chunk of it, which is what `compress_bodies` now does
/// before taking the lock.
mod zstd_encode {
use super::*;
/// Level 3, matching `db/repo.rs`'s `ZSTD_LEVEL`. Not a variable here:
/// the level is argued in place and this measures the machinery around
/// it, not the level.
const LEVEL: i32 = 3;
#[divan::bench(args = corpus::SIZES)]
fn encode_all(bencher: Bencher, size: usize) {
let text = corpus::text(size, 4).as_bytes();
bencher.bench(|| zstd::encode_all(divan::black_box(text), LEVEL).unwrap());
}
#[divan::bench(args = corpus::SIZES)]
fn bulk_reused(bencher: Bencher, size: usize) {
let text = corpus::text(size, 4).as_bytes();
let mut enc = zstd::bulk::Compressor::new(LEVEL).unwrap();
bencher.bench_local(move || enc.compress(divan::black_box(text)).unwrap());
}
/// One writer chunk: `processing.batch_size` documents of the size most
/// documents are.
///
/// The `encode_all` figure is how much pure CPU used to sit inside the
/// `conn_mutex` hold. That lock serializes the indexer against itself —
/// not against search, which holds its own connection and reads through
/// WAL — so this is a contention figure, not a throughput one. An
/// end-to-end cold index of an 80 MiB tree does not move measurably: FTS5
/// trigram tokenization dominates it, and all compression together is
/// under 1% of the run.
const BATCH: usize = 500;
#[divan::bench]
fn batch_encode_all(bencher: Bencher) {
let text = corpus::text(1 << 10, 4).as_bytes();
bencher.bench(|| {
(0..BATCH)
.map(|_| {
zstd::encode_all(divan::black_box(text), LEVEL)
.unwrap()
.len()
})
.sum::<usize>()
});
}
#[divan::bench]
fn batch_bulk_reused(bencher: Bencher) {
let text = corpus::text(1 << 10, 4).as_bytes();
let mut enc = zstd::bulk::Compressor::new(LEVEL).unwrap();
bencher.bench_local(move || {
(0..BATCH)
.map(|_| enc.compress(divan::black_box(text)).unwrap().len())
.sum::<usize>()
});
}
}
/// What a small text file costs between the MIME sniff and the decode.
///
/// The walk reads an 8 KiB head, sniffs it (`looks_like_text` → `classify`, a
/// control-byte scan plus a UTF-8 validation), and then — for a file the head
/// covers entirely — decodes it inline (`decode_text` → `classify` again, on
/// the same bytes). So `classify` runs twice on identical bytes, and
/// `sniff_only` says the second pass is about half of `sniff_then_decode`.
///
/// Left alone deliberately. The two calls pass different `truncated` flags and
/// genuinely disagree on a file ending mid-multibyte-sequence, so sharing a
/// verdict means threading `TextClass` through `mime.rs`, the `Extractor`
/// trait and `plaintext.rs`. Against a cold `indexprobe` run that whole
/// pipeline is under 1% of wall time — the redundancy is real and still not
/// worth the coupling. Re-measure here before deciding otherwise.
mod text_pipeline {
use super::*;
use std::path::Path;
#[divan::bench]
fn sniff_then_decode(bencher: Bencher) {
let head = corpus::text_head();
let path = Path::new("/tmp/bench/notes.txt");
bencher.bench(|| {
let looks = textenc::looks_like_text(divan::black_box(head));
let text = textenc::decode_text(head.to_vec(), path).unwrap();
(looks, text.len())
});
}
#[divan::bench]
fn sniff_only(bencher: Bencher) {
let head = corpus::text_head();
bencher.bench(|| textenc::looks_like_text(divan::black_box(head)));
}
/// Includes the `head.to_vec()` at `extract/plaintext.rs:113` — a full
/// copy of the head made only because `decode_text` takes ownership.
#[divan::bench]
fn decode_only(bencher: Bencher) {
let head = corpus::text_head();
let path = Path::new("/tmp/bench/notes.txt");
bencher.bench(|| textenc::decode_text(divan::black_box(head).to_vec(), path));
}
}
/// The MIME sniff itself, once per new or changed file.
///
/// It carries several `to_ascii_lowercase` allocations for extension and MIME
/// comparisons that `eq_ignore_ascii_case` would do without allocating — and
/// removing them is not worth doing: the whole sniff is ~180 ns on a text head,
/// against ~2.2 µs for the `classify` next to it. This group exists to keep
/// that ratio visible.
mod mime_sniff {
use super::*;
use std::path::Path;
#[divan::bench]
fn text_head(bencher: Bencher) {
let head = corpus::text_head();
let path = Path::new("/tmp/bench/notes.txt");
bencher.bench(|| mime::guess_mime_from_head(path, divan::black_box(head)));
}
#[divan::bench]
fn binary_head(bencher: Bencher) {
let head = corpus::binary_head();
let path = Path::new("/tmp/bench/blob.bin");
bencher.bench(|| mime::guess_mime_from_head(path, divan::black_box(head)));
}
/// No extension to go on, so the sniff falls all the way through to the
/// magic-byte scan and the text classifier.
#[divan::bench]
fn no_extension(bencher: Bencher) {
let head = corpus::text_head();
let path = Path::new("/tmp/bench/LICENSE");
bencher.bench(|| mime::guess_mime_from_head(path, divan::black_box(head)));
}
}
/// A SHA-256 over the path string, per file, on every run — including the
/// unchanged steady state, where it is the only compute a file costs beyond
/// its `statx`.
///
/// Reference measurement only. The plan does not propose changing it: its
/// rationale is collision resistance against adversarial filenames on shared
/// volumes, and this is here to confirm the cost is small enough that the
/// argument stands unchallenged.
mod path_digest {
use super::*;
#[divan::bench]
fn per_path(bencher: Bencher) {
let rows = corpus::rows();
bencher.bench(|| {
let mut acc = 0u128;
for row in divan::black_box(rows) {
acc ^= walk::path_digest(&row.path);
}
acc
});
}
}

View file

@ -0,0 +1,363 @@
//! Search-path microbenchmarks.
//!
//! Every group pairs two ways of doing the same work in one run, so the delta
//! is a measurement rather than an estimate. Some pairs justify a choice the
//! code has already made; others record something tried and rejected. Both are
//! worth keeping — a losing arm is the cheapest documentation there is that an
//! obvious-looking idea was measured and did not pay.
//!
//! Run with:
//!
//! ```text
//! cargo bench -p quicksearch-core --bench search
//! ```
//!
//! Sizes come from `corpus::SIZES` — 1 KiB, 16 KiB and 256 KiB, the last
//! being `maximum_text_size`, the largest document the index will hold and so
//! the worst case a full-text row can present.
mod corpus;
use divan::Bencher;
use quicksearch_core::query::pattern::{TermPart, TermPattern};
use quicksearch_core::search::fuzzy::Bitap;
use quicksearch_core::snippet;
fn main() {
divan::main();
}
fn literal(term: &str) -> TermPattern {
TermPattern::build(&[TermPart {
text: term.to_string(),
glob: false,
}])
.expect("literal patterns always compile")
}
/// Decompressing the stored document body — the first thing every full-text
/// row does, at `search/cascade/passes.rs:241`, `:425` and `:528`.
///
/// `decode_all` builds a fresh `ZSTD_DCtx` and a ~131 KB `BufReader` per call,
/// then grows an unsized `Vec` as it goes; the other arm reuses one context and
/// sizes the output up front. Measured at 8.4/16.0/102 µs against
/// 1.6/8.0/89 µs — a 4.4x gap at 1 KiB, which is the size most documents are.
/// This is why `DocDecoder` exists.
mod zstd_decode {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn decode_all(bencher: Bencher, size: usize) {
let blob = corpus::blob(size, 4);
bencher.bench(|| zstd::decode_all(divan::black_box(blob)).unwrap());
}
#[divan::bench(args = corpus::SIZES)]
fn bulk_reused(bencher: Bencher, size: usize) {
let blob = corpus::blob(size, 4);
let capacity = corpus::text(size, 4).len();
let mut dec = zstd::bulk::Decompressor::new().unwrap();
bencher.bench_local(move || dec.decompress(divan::black_box(blob), capacity).unwrap());
}
}
/// Turning decompressed bytes into a `&str`.
///
/// `from_utf8_lossy(..).into_owned()` copies the whole document even when the
/// bytes are already valid UTF-8 — and they always are, since
/// `textenc::decode_text` is the only thing that writes them. It is also far
/// slower than it looks: its validation is a scanning loop, where
/// `String::from_utf8` uses the vectorized one and *moves* the buffer it
/// validates. 230/6360/52200 ns against 15/227/3200 ns, a 16-28x gap that is
/// mostly validation rather than the copy. `DocDecoder` borrows instead.
mod utf8 {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn lossy_into_owned(bencher: Bencher, size: usize) {
let raw = corpus::text(size, 4).as_bytes();
bencher.bench(|| String::from_utf8_lossy(divan::black_box(raw)).into_owned());
}
#[divan::bench(args = corpus::SIZES)]
fn lossy_borrowed(bencher: Bencher, size: usize) {
let raw = corpus::text(size, 4).as_bytes();
bencher.bench(|| {
let cow = String::from_utf8_lossy(divan::black_box(raw));
cow.len()
});
}
/// The decompressor hands back an owned `Vec<u8>` that nothing else
/// references, so `String::from_utf8` can validate and *move* it rather
/// than validate and copy. Falling back to `from_utf8_lossy` on error
/// keeps the current behaviour for a corrupt row exactly.
#[divan::bench(args = corpus::SIZES)]
fn from_utf8_move(bencher: Bencher, size: usize) {
let raw = corpus::text(size, 4).as_bytes();
bencher
.with_inputs(|| raw.to_vec())
.bench_values(|owned| match String::from_utf8(owned) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
});
}
}
/// ASCII-folding the document, which every full-text row needs for the
/// case-insensitive count and the snippet.
///
/// A result worth keeping visible: folding into a reused buffer is *not*
/// faster. `to_ascii_lowercase` allocates and folds in one pass, where
/// clear + `push_str` + `make_ascii_lowercase` walks the bytes twice, and at
/// 256 KiB the reused buffer measures slightly behind. `fold_into` is chosen
/// for what it does to the allocator, not to the clock — do not "optimize" the
/// other direction on the assumption that removing an allocation must win.
mod fold {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn to_ascii_lowercase(bencher: Bencher, size: usize) {
let text = corpus::text_mixed(size, 4);
bencher.bench(|| divan::black_box(text).to_ascii_lowercase());
}
#[divan::bench(args = corpus::SIZES)]
fn into_reused_buffer(bencher: Bencher, size: usize) {
let text = corpus::text_mixed(size, 4);
let mut buf = String::new();
bencher.bench_local(move || {
buf.clear();
buf.push_str(divan::black_box(text));
// SAFETY-free equivalent of the in-place fold: `make_ascii_lowercase`
// is byte-length preserving, which is the same invariant the
// cascade already relies on for folded offsets.
buf.make_ascii_lowercase();
buf.len()
});
}
}
/// Substring search over a document body: `str::match_indices` (std's Two-Way
/// searcher) against `memchr::memmem` (Two-Way plus a SIMD prefilter).
///
/// The miss case matters most. The trigram index matches on character triples,
/// so a full-text pass verifies far more rows than it accepts, and a miss scans
/// the whole document before giving up. At 256 KiB that is 111 µs against
/// 2.4 µs — the measurement `snippet.rs` uses `memmem` for. `match_indices`
/// stays here as the regression guard: if these two ever converge, the SIMD
/// path has stopped being selected.
mod substring {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn match_indices_miss(bencher: Bencher, size: usize) {
let text = corpus::text(size, 0);
bencher.bench(|| divan::black_box(text).match_indices(corpus::NEEDLE).count());
}
#[divan::bench(args = corpus::SIZES)]
fn memmem_miss(bencher: Bencher, size: usize) {
let text = corpus::text(size, 0).as_bytes();
let finder = memchr::memmem::Finder::new(corpus::NEEDLE);
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
}
#[divan::bench(args = corpus::SIZES)]
fn match_indices_hits(bencher: Bencher, size: usize) {
let text = corpus::text(size, 64);
bencher.bench(|| divan::black_box(text).match_indices(corpus::NEEDLE).count());
}
#[divan::bench(args = corpus::SIZES)]
fn memmem_hits(bencher: Bencher, size: usize) {
let text = corpus::text(size, 64).as_bytes();
let finder = memchr::memmem::Finder::new(corpus::NEEDLE);
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
}
/// What `pass_fulltext` actually runs per row, through the real crate
/// entry points: a case-sensitive count, then a folded count, then the
/// snippet extraction. Three sweeps of the same document.
#[divan::bench(args = corpus::SIZES)]
fn cascade_row_sweeps(bencher: Bencher, size: usize) {
let pattern = literal(corpus::NEEDLE);
let text = corpus::text_mixed(size, 4);
let folded = corpus::text_folded(size, 4);
let opts = snippet::Options { approx_chars: 600 };
bencher.bench(|| {
let a = pattern.count(divan::black_box(text), false);
let b = pattern.count_folded(divan::black_box(folded));
let s = snippet::extract_folded(text, folded, &[corpus::NEEDLE], &opts);
(a, b, s.ranges.len())
});
}
}
/// Snippet extraction against a pre-folded haystack, the third of those
/// sweeps. Also carries a per-call `term.to_ascii_lowercase()` at
/// `snippet.rs:81` for a needle the caller already holds folded.
mod snippet_extract {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn extract_folded(bencher: Bencher, size: usize) {
let text = corpus::text_mixed(size, 64);
let folded = corpus::text_folded(size, 64);
let opts = snippet::Options { approx_chars: 600 };
bencher.bench(|| {
snippet::extract_folded(
divan::black_box(text),
divan::black_box(folded),
&[corpus::NEEDLE],
&opts,
)
});
}
}
/// The filename pass's per-row ladder, over 2000 realistic name/path rows.
///
/// `pass_filename` scans the whole `files` table — its `LIKE '%term%'`
/// predicate can use no index — and tiers 4 and 10 both run a
/// case-insensitive find, so a row matching on its directory portion pays
/// twice.
///
/// The instructive part is that the two obvious fixes each make it *worse*
/// alone: a reused fold buffer measures ~2x slower than folding into a fresh
/// allocation, and a prebuilt `memmem::Finder` is slower than `str::find` on
/// haystacks this short. Only together do they win, and only by ~1.2x. Short
/// strings do not behave like document bodies; measure them separately.
mod filename_ladder {
use super::*;
#[divan::bench]
fn find_first_ci_current(bencher: Bencher) {
let pattern = literal("quartzite");
let rows = corpus::rows();
bencher.bench(|| {
let mut found = 0usize;
for row in divan::black_box(rows) {
if pattern.find_first(&row.name, true).is_some()
|| pattern.find_first(&row.path, true).is_some()
{
found += 1;
}
}
found
});
}
#[divan::bench]
fn find_first_ci_scratch(bencher: Bencher) {
let pattern = literal("quartzite");
let rows = corpus::rows();
let mut scratch = String::new();
bencher.bench_local(move || {
let mut found = 0usize;
for row in divan::black_box(rows) {
scratch.clear();
scratch.push_str(&row.name);
scratch.make_ascii_lowercase();
if pattern.find_first_folded(&scratch).is_some() {
found += 1;
continue;
}
scratch.clear();
scratch.push_str(&row.path);
scratch.make_ascii_lowercase();
if pattern.find_first_folded(&scratch).is_some() {
found += 1;
}
}
found
});
}
/// Fold as today, but search the folded copy with a `Finder` built once
/// per query instead of `str::find`'s Two-Way. Isolates the searcher from
/// the allocation: if this wins and `find_first_ci_scratch` does not, the
/// fold was never the problem.
#[divan::bench]
fn find_first_ci_memmem(bencher: Bencher) {
let finder = memchr::memmem::Finder::new("quartzite");
let rows = corpus::rows();
bencher.bench(|| {
let mut found = 0usize;
for row in divan::black_box(rows) {
if finder
.find(row.name.to_ascii_lowercase().as_bytes())
.is_some()
|| finder
.find(row.path.to_ascii_lowercase().as_bytes())
.is_some()
{
found += 1;
}
}
found
});
}
/// Both at once: one reused fold buffer and a prebuilt `Finder`.
#[divan::bench]
fn find_first_ci_scratch_memmem(bencher: Bencher) {
let finder = memchr::memmem::Finder::new("quartzite");
let rows = corpus::rows();
let mut scratch = String::new();
bencher.bench_local(move || {
let mut found = 0usize;
for row in divan::black_box(rows) {
scratch.clear();
scratch.push_str(&row.name);
scratch.make_ascii_lowercase();
if finder.find(scratch.as_bytes()).is_some() {
found += 1;
continue;
}
scratch.clear();
scratch.push_str(&row.path);
scratch.make_ascii_lowercase();
if finder.find(scratch.as_bytes()).is_some() {
found += 1;
}
}
found
});
}
}
/// Bitap, the fuzzy passes' inner loop. Both fuzzy passes are whole-table
/// scans, so this runs over every row in the index when fuzzy is on.
///
/// `step` still takes `&mut [u64]` rather than `&mut [u64; MAX_REGISTERS]`,
/// so the register indices are bounds-checked and the trip count is opaque
/// to the optimizer.
mod bitap {
use super::*;
#[divan::bench(args = corpus::SIZES)]
fn count_and_first_k2(bencher: Bencher, size: usize) {
let bitap = Bitap::new(corpus::NEEDLE.as_bytes(), 2).unwrap();
let hay = corpus::text(size, 4).as_bytes();
bencher.bench(|| bitap.count_and_first(divan::black_box(hay)));
}
/// The filename pass's shape: many short haystacks rather than one long
/// one, with the per-call 176-byte register memset amortized over very
/// little work.
#[divan::bench]
fn best_distance_over_names_k2(bencher: Bencher) {
let bitap = Bitap::new(b"quartzite", 2).unwrap();
let rows = corpus::rows();
bencher.bench(|| {
let mut hits = 0usize;
for row in divan::black_box(rows) {
if bitap.best_distance_and_first(row.name.as_bytes()).is_some() {
hits += 1;
}
}
hits
});
}
}

View file

@ -251,7 +251,13 @@ fn run(mode: &str, tree: &Path, db: &Path) {
assert!(done, "indexing did not finish within the timeout");
service.stop_indexing().expect("stop");
let total = SMALL_TEXT + LARGE_TEXT + BINARY;
// Count what was actually indexed rather than assuming `gen`'s tree.
// The constants describe the tree this probe builds; pointing it at any
// other one made the rate a fiction.
let total = rusqlite::Connection::open(db)
.ok()
.and_then(|c| quicksearch_core::db::repo::row_count(&c).ok())
.unwrap_or(0);
eprintln!(
"{}: {:?} ({:.0} files/sec over {} files)",
mode,

View file

@ -221,6 +221,7 @@ mod tests {
use crate::db::open_or_recreate;
use crate::db::repo::{insert_file, set_content_done, set_content_failed, NewFile};
use crate::mime::FileType;
use crate::testutil::zstd_of;
fn tmp_path() -> std::path::PathBuf {
crate::testutil::scratch_dir("cli").join("index.sqlite")
@ -248,7 +249,7 @@ mod tests {
)
.unwrap()
.expect("unique path");
set_content_done(&tx, a, "a.txt", "hello", &[], true).unwrap();
set_content_done(&tx, a, "a.txt", "hello", &[], zstd_of("hello").as_deref()).unwrap();
let b = insert_file(
&tx,
&NewFile {
@ -427,7 +428,7 @@ mod tests {
.unwrap()
.expect("unique path");
let prose = "the quick brown fox jumps over the lazy dog. ".repeat(500);
set_content_done(&tx, id, "big.txt", &prose, &[], true).unwrap();
set_content_done(&tx, id, "big.txt", &prose, &[], zstd_of(&prose).as_deref()).unwrap();
tx.commit().unwrap();
}
drop(conn);

View file

@ -104,6 +104,23 @@ pub struct IndexerState {
/// A run's *own* reconciliation is not here — it reads as
/// [`IndexingStatus::Preparing`] with a [`PrepStep::Reconciling`].
pub reconcile: Option<ReconcileState>,
/// What each configured root held when indexing last completed. Roots
/// never indexed to completion are absent rather than zero.
///
/// `Arc` because `state()` is called more than once per frame and this
/// changes only when a run ends.
pub root_counts: Arc<Vec<RootCount>>,
}
/// One configured root's stored figures, keyed the way the caller spells it.
#[derive(Debug, Clone)]
pub struct RootCount {
/// The root exactly as `paths.indexing_paths` gives it, so a frontend can
/// match it against the string it already draws. The `schema_info` key
/// behind it is the canonicalized spelling, so re-spelling a root in the
/// config keeps its figures.
pub root: String,
pub counts: db::repo::RootCounts,
}
#[allow(clippy::large_enum_variant)]
@ -161,6 +178,7 @@ struct Shared {
queued_events: usize,
watcher: WatcherStatus,
reconcile: Option<ReconcileState>,
root_counts: Arc<Vec<RootCount>>,
}
impl IndexCoordinator {
@ -192,6 +210,7 @@ impl IndexCoordinator {
queued_events: 0,
watcher: WatcherStatus::Off,
reconcile: None,
root_counts: Arc::new(Vec::new()),
}));
let reconcile_stop = Arc::new(ReconcileStop::default());
@ -251,6 +270,7 @@ impl IndexCoordinator {
queued_events: shared.queued_events,
watcher: shared.watcher.clone(),
reconcile: shared.reconcile,
root_counts: shared.root_counts.clone(),
}
}

View file

@ -116,6 +116,9 @@ impl Inner {
// restart is cheap and unconditional beats a diff here.
self.start_watcher();
}
// The root list, its spellings, or the database behind it may
// all have moved; re-pair them with what is stored.
self.refresh_last_full_index();
}
CoordCmd::RebuildIndex => {
let db = self.db_path();
@ -127,6 +130,7 @@ impl Inner {
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
crate::log_warn!("coordinator: rebuild: {}", e);
}
self.clear_root_counts();
self.start_full_run();
if self.mode != IndexMode::Auto {
self.mode = IndexMode::ManualRunning;
@ -148,6 +152,9 @@ impl Inner {
// Zero, not `None`: nothing will rebuild this index, so no
// later read corrects a stale figure.
shared.files = Some(0);
// Per root the empty list reads as "not yet indexed", which is
// what every folder now is.
shared.root_counts = Arc::new(Vec::new());
drop(shared);
self.files_at = None;
}
@ -717,7 +724,13 @@ impl Inner {
}
}
/// Re-read the stamp the last completed full run left behind.
/// Re-read what the last completed full run left behind: its stamp, and
/// the per-root figures the folder list shows.
///
/// Both off one connection because they are wanted at the same moments —
/// startup, a run finishing, a config change. Neither is a scan: the stamp
/// and each root's counts are single `schema_info` key lookups, the work of
/// counting having been done by the run that stored them.
///
/// A failed open is *not* published as `None`: `periodic_due` reads `None`
/// as "never indexed" and would start a fresh run every tick for as long
@ -726,12 +739,45 @@ impl Inner {
match db::open_existing(&self.db_path(), false) {
Ok(conn) => {
let last = db::repo::get_last_full_index(&conn);
crate::lock_ok(&self.shared).last_full_index = last;
let counts = self.read_root_counts(&conn);
let mut shared = crate::lock_ok(&self.shared);
shared.last_full_index = last;
shared.root_counts = Arc::new(counts);
}
Err(e) => crate::log_warn!("coordinator: last-full-index unreadable: {}", e),
}
}
/// Pair every configured root with its stored figures, keyed by the
/// spelling the config uses so a frontend can match what it draws.
///
/// The `schema_info` keys are canonicalized, which is what makes writing
/// `~/docs` where the config said `/home/me/docs` keep the figures — the
/// same re-keying `indexing::resolved_root_workers` does in the other
/// direction.
fn read_root_counts(&self, conn: &Connection) -> Vec<RootCount> {
self.config
.paths
.indexing_paths
.iter()
.zip(self.config.resolved_indexing_paths())
.filter_map(|(raw, resolved)| {
let root = crate::file_handling::normalize_root_string(&resolved.to_string_lossy());
let counts = db::repo::get_root_counts(conn, &root)?;
Some(RootCount {
root: raw.clone(),
counts,
})
})
.collect()
}
/// Forget the published figures: the index behind them is gone, and
/// nothing will correct them until a run rebuilds it.
fn clear_root_counts(&self) {
crate::lock_ok(&self.shared).root_counts = Arc::new(Vec::new());
}
fn publish(&mut self) {
let reconcile = match &self.pending_work {
Some(cursor) => Some(ReconcileState::Running(cursor.progress())),

View file

@ -166,13 +166,22 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
/// result rows can't render snippets). `properties` are stored both as a
/// structured side-table (exact retrieval) and concatenated into the FTS
/// `properties` column (MATCH).
/// `text_zstd` is the already-compressed body for the `documents_text`
/// sidecar, or `None` to write no sidecar at all (an empty body, or
/// `store_text_for_snippets` off).
///
/// Compression is the caller's job, and deliberately so: it is the expensive
/// half of a content write, and this runs inside the writer's transaction
/// with the shared connection held. Callers on the indexing path compress a
/// whole batch through one [`DocEncoder`] *before* taking the lock, so the
/// transaction only binds finished blobs.
pub fn set_content_done(
tx: &Transaction<'_>,
file_id: i64,
name: &str,
text: &str,
properties: &[(String, String)],
store_text: bool,
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
remove_content_for_id(tx, file_id)?;
@ -195,10 +204,8 @@ pub fn set_content_done(
)?;
// No sidecar row for empty body text (e.g. an image whose extractor
// returned only EXIF properties).
if store_text && !text.is_empty() {
let compressed = zstd::encode_all(text.as_bytes(), ZSTD_LEVEL)
.map_err(|e| format!("zstd encode for file {}: {}", file_id, e))?;
// returned only EXIF properties) — the caller passes `None` for that.
if let Some(compressed) = text_zstd {
exec(
tx,
"INSERT INTO documents_text(file_id, text_zstd, text_len) VALUES (?1, ?2, ?3)",
@ -215,6 +222,45 @@ pub fn set_content_done(
/// readers decompress far faster than writers compress.
const ZSTD_LEVEL: i32 = 3;
/// Reusable compression context for the `documents_text` sidecar — the write
/// side's mirror of the cascade's `DocDecoder`.
///
/// `zstd::encode_all` builds and tears down a `ZSTD_CCtx` — window, hash and
/// chain tables — on every call, and the writer calls it once per extracted
/// document. At 1 KiB, the size most documents actually are, that setup costs
/// more than the compression: 16.4 µs against 3.4 µs for the same bytes
/// through a context that already exists (`benches/index.rs`, group
/// `zstd_encode`). One encoder per batch makes it a per-batch cost.
pub struct DocEncoder(zstd::bulk::Compressor<'static>);
impl DocEncoder {
pub fn new() -> Result<DocEncoder, String> {
zstd::bulk::Compressor::new(ZSTD_LEVEL)
.map(DocEncoder)
.map_err(|e| format!("zstd encoder: {}", e))
}
/// Compress `text` for [`set_content_done`]'s `text_zstd` argument.
pub fn encode(&mut self, text: &str) -> Result<Vec<u8>, String> {
self.0
.compress(text.as_bytes())
.map_err(|e| format!("zstd encode: {}", e))
}
}
/// Compress one body, for the writers that handle a single row.
///
/// The batch writers reuse one [`DocEncoder`] across a chunk and run it
/// outside the connection lock. The single-row paths — the watcher, and a
/// walk-time inline body — write one row per transaction, so there is no
/// batch to amortize a context over and this builds one for the document.
pub fn encode_one(text: &str, store_text: bool) -> Result<Option<Vec<u8>>, String> {
if !store_text || text.is_empty() {
return Ok(None);
}
DocEncoder::new()?.encode(text).map(Some)
}
/// Mark a file's content extraction as failed. Keeps the basic row in place.
pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> {
let now = crate::log::now_unix() as i64;
@ -442,6 +488,44 @@ pub fn row_count(conn: &Connection) -> Result<usize, String> {
.map_err(|e| format!("count indexed files: {}", e))
}
/// What one root holds: rows under it, and how many of those are searchable
/// by content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RootCounts {
pub files: i64,
/// Rows carrying a `searchabletext` entry.
pub fts: i64,
}
/// Count the rows in the half-open path range `[lo, hi)` and, in the same
/// pass, how many of them have a full-text row.
///
/// `content_state = STATE_DONE` *is* "has a `searchabletext` row":
/// [`set_content_done`] holds the only insert into that table and is what
/// writes the state, and [`remove_content_for_id`] clears the two together.
/// Asking `files` is what makes both figures one statement — the FTS table is
/// contentless and keyed by `rowid`, so it has no path to range-scan on.
///
/// One statement, but not a cheap one: `content_state` is not carried by the
/// `UNIQUE(files.path)` index the range seeks on, so every row in the range is
/// fetched. Call it where a run has just read those rows anyway, not on a
/// cadence.
pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, String> {
conn.prepare_cached(
"SELECT COUNT(*), COALESCE(SUM(content_state = ?3), 0) FROM files
WHERE path >= ?1 AND path < ?2",
)
.and_then(|mut stmt| {
stmt.query_row(params![lo, hi, STATE_DONE], |r| {
Ok(RootCounts {
files: r.get(0)?,
fts: r.get(1)?,
})
})
})
.map_err(|e| format!("count root {}: {}", lo, e))
}
/// One page of rows whose path is `> after` and `< hi`, in path order.
///
/// Keyset on `path`: every page is an index walk with no sort step, and a row
@ -554,14 +638,24 @@ pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, Stri
pub fn remove_content_for_id(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
// `contentless_delete=1` on the FTS5 table makes this work without
// re-supplying the old column values (it tombstones the rowid).
for (table, key) in [
("searchabletext", "rowid"),
("documents_text", "file_id"),
("properties", "file_id"),
//
// Spelled out rather than built from a (table, key) table: this runs for
// every extracted document and every changed file, and `format!`ing three
// constant strings per call also handed `prepare_cached` three freshly
// allocated keys to hash.
for (what, sql) in [
(
"searchabletext",
"DELETE FROM searchabletext WHERE rowid = ?1",
),
(
"documents_text",
"DELETE FROM documents_text WHERE file_id = ?1",
),
("properties", "DELETE FROM properties WHERE file_id = ?1"),
] {
let sql = format!("DELETE FROM {} WHERE {} = ?1", table, key);
exec(tx, &sql, params![file_id], || {
format!("delete {} for {}", table, file_id)
exec(tx, sql, params![file_id], || {
format!("delete {} for {}", what, file_id)
})?;
}
Ok(())
@ -705,9 +799,24 @@ pub fn set_last_full_index(conn: &Connection, ts: u64) -> Result<(), String> {
Ok(())
}
/// The `schema_info` key prefixes holding per-root figures. Every one of them
/// is swept by [`prune_root_stats`], so a new prefix belongs in this list or a
/// de-configured root leaves it behind forever.
const ROOT_STAT_PREFIXES: [&str; 2] = ["walk_count:", "counts:"];
/// `schema_info` key holding one root's figure of the given kind.
fn root_key(prefix: &str, root: &str) -> String {
format!("{}{}", prefix, root)
}
/// `schema_info` key holding one root's last known file count.
fn walk_count_key(root: &str) -> String {
format!("walk_count:{}", root)
root_key(ROOT_STAT_PREFIXES[0], root)
}
/// `schema_info` key holding one root's last completed run's [`RootCounts`].
fn counts_key(root: &str) -> String {
root_key(ROOT_STAT_PREFIXES[1], root)
}
/// How many files the last clean walk of `root` reported — the progress bar's
@ -741,22 +850,64 @@ pub fn set_root_walk_count(conn: &Connection, root: &str, n: usize) -> Result<()
Ok(())
}
/// Forget the stored counts of roots that are no longer configured, so a
/// root removed and later re-added does not start from a stale count.
pub fn prune_root_walk_counts(conn: &Connection, keep: &[String]) -> Result<(), String> {
let keep: std::collections::HashSet<String> = keep.iter().map(|r| walk_count_key(r)).collect();
/// What the last completed run counted under `root`, if one has finished
/// since the root was configured. Absent — never indexed, cleared, or a
/// value this build cannot parse — reads as `None`, like the walk count.
pub fn get_root_counts(conn: &Connection, root: &str) -> Option<RootCounts> {
let stored: String = conn
.query_row(
"SELECT value FROM schema_info WHERE key = ?1",
params![counts_key(root)],
|r| r.get(0),
)
.optional()
.ok()
.flatten()?;
let (files, fts) = stored.split_once(',')?;
Some(RootCounts {
files: files.parse().ok()?,
fts: fts.parse().ok()?,
})
}
/// Record what `root` holds, for the folder list to show once the run that
/// counted it is over.
///
/// Written only at the end of a run that completed: a stopped one has counted
/// part of a tree it was still changing, and the previous figure is closer to
/// the truth than that.
pub fn set_root_counts(conn: &Connection, root: &str, counts: RootCounts) -> Result<(), String> {
conn.execute(
"INSERT OR REPLACE INTO schema_info(key, value) VALUES (?1, ?2)",
params![counts_key(root), format!("{},{}", counts.files, counts.fts)],
)
.map_err(|e| format!("write counts for {}: {}", root, e))?;
Ok(())
}
/// Forget the stored figures of roots that are no longer configured, so a
/// root removed and later re-added does not start from stale ones.
pub fn prune_root_stats(conn: &Connection, keep: &[String]) -> Result<(), String> {
let keep: std::collections::HashSet<String> = ROOT_STAT_PREFIXES
.iter()
.flat_map(|prefix| keep.iter().map(move |r| root_key(prefix, r)))
.collect();
// Filtered here rather than with a `LIKE` per prefix: `schema_info` holds
// a handful of keys plus these, and a SQL pattern list would be a second
// spelling of `ROOT_STAT_PREFIXES` to keep in step with the first.
let mut stmt = conn
.prepare("SELECT key FROM schema_info WHERE key LIKE 'walk_count:%'")
.map_err(|e| format!("read walk counts: {}", e))?;
.prepare("SELECT key FROM schema_info")
.map_err(|e| format!("read root stats: {}", e))?;
let stored: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| format!("read walk counts: {}", e))?
.map_err(|e| format!("read root stats: {}", e))?
.filter_map(|r| r.ok())
.filter(|k| ROOT_STAT_PREFIXES.iter().any(|p| k.starts_with(p)))
.collect();
drop(stmt);
for key in stored.iter().filter(|k| !keep.contains(*k)) {
conn.execute("DELETE FROM schema_info WHERE key = ?1", params![key])
.map_err(|e| format!("drop walk count {}: {}", key, e))?;
.map_err(|e| format!("drop root stat {}: {}", key, e))?;
}
Ok(())
}

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use super::*;
use crate::db::open_or_recreate;
use crate::testutil::zstd_of;
fn tmp_path() -> std::path::PathBuf {
crate::testutil::scratch_dir("repo").join("index.sqlite")
@ -38,7 +39,7 @@ fn insert_update_delete_round_trip() {
"a.txt",
"hello world",
&[("title".to_string(), "hi".to_string())],
true,
zstd_of("hello world").as_deref(),
)
.unwrap();
tx.commit().unwrap();
@ -136,7 +137,15 @@ fn update_writes_content_state_from_needs_content() {
let id = {
let tx = conn.transaction().unwrap();
let id = insert_file(&tx, &row).unwrap().expect("unique path");
set_content_done(&tx, id, "a.txt", "old text", &[], true).unwrap();
set_content_done(
&tx,
id,
"a.txt",
"old text",
&[],
zstd_of("old text").as_deref(),
)
.unwrap();
tx.commit().unwrap();
id
};
@ -265,7 +274,15 @@ fn delete_subtree_clears_every_dependent_table() {
)
.unwrap()
.expect("unique path");
set_content_done(tx, id, name, "body text", &[("k".into(), "v".into())], true).unwrap();
set_content_done(
tx,
id,
name,
"body text",
&[("k".into(), "v".into())],
zstd_of("body text").as_deref(),
)
.unwrap();
id
};
@ -351,7 +368,7 @@ fn seeded(conn: &mut Connection, paths: &[&str]) -> std::collections::HashMap<St
name,
"body text",
&[("k".into(), "v".into())],
true,
zstd_of("body text").as_deref(),
)
.unwrap();
ids.insert((*path).to_string(), id);
@ -765,7 +782,7 @@ fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
&name,
&"lorem ipsum dolor sit amet ".repeat(64),
&[],
true,
zstd_of(&"lorem ipsum dolor sit amet ".repeat(64)).as_deref(),
)
.unwrap();
}
@ -1038,3 +1055,181 @@ fn set_content_failed_writes_failed_table() {
drop(conn);
std::fs::remove_file(&p).ok();
}
/// Insert one row under `path`, born pending when `needs_content`.
fn insert_at(tx: &Transaction<'_>, path: &str, needs_content: bool) -> i64 {
let name = path.rsplit('/').next().unwrap();
let parent = &path[..path.rfind('/').unwrap()];
insert_file(
tx,
&NewFile {
name,
path,
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content,
},
)
.unwrap()
.expect("unique path")
}
fn fts_rows(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM searchabletext", [], |r| r.get(0))
.unwrap()
}
/// The whole premise of answering both figures from `files`: a row reads
/// `content_state = DONE` exactly when it has a `searchabletext` row, so the
/// conditional sum *is* a count of the FTS table restricted to a path range.
///
/// Pinned against the FTS table itself rather than against the states that
/// were written, because the equivalence is what would break if some future
/// transition wrote one without the other.
#[test]
fn count_root_counts_the_fts_rows_it_says_it_does() {
let p = tmp_path();
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
{
let tx = conn.transaction().unwrap();
// Two searchable, and one of each way a row can fail to be.
for path in ["/tree/a.txt", "/tree/b.txt"] {
let id = insert_at(&tx, path, true);
set_content_done(
&tx,
id,
"n",
"body text",
&[],
zstd_of("body text").as_deref(),
)
.unwrap();
}
let failed = insert_at(&tx, "/tree/c.bin", true);
set_content_failed(&tx, failed, "bad parse").unwrap();
let na = insert_at(&tx, "/tree/d.iso", true);
set_content_na(&tx, na).unwrap();
insert_at(&tx, "/tree/e.txt", true); // still pending
tx.commit().unwrap();
}
let counts = count_root(&conn, "/tree/", "/tree0").unwrap();
assert_eq!(counts.files, 5, "every row under the root");
assert_eq!(
counts.fts,
fts_rows(&conn),
"the root holds everything, so its FTS figure is the whole table"
);
assert_eq!(counts.fts, 2);
// A searchable row outside the range moves the table's total and not the
// root's figure — otherwise the assertion above would hold for a count
// that ignored its bounds.
{
let tx = conn.transaction().unwrap();
let id = insert_at(&tx, "/elsewhere/f.txt", true);
set_content_done(
&tx,
id,
"n",
"body text",
&[],
zstd_of("body text").as_deref(),
)
.unwrap();
tx.commit().unwrap();
}
assert_eq!(fts_rows(&conn), 3);
assert_eq!(
count_root(&conn, "/tree/", "/tree0").unwrap(),
counts,
"a row outside the range belongs to no root's figures"
);
drop(conn);
std::fs::remove_file(&p).ok();
}
/// An empty range is 0/0, not an error: a configured root nothing has been
/// walked into yet is a normal state, and `SUM` over no rows is NULL.
#[test]
fn count_root_reports_zero_for_an_empty_range() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
assert_eq!(
count_root(&conn, "/nothing/", "/nothing0").unwrap(),
RootCounts { files: 0, fts: 0 }
);
drop(conn);
std::fs::remove_file(&p).ok();
}
#[test]
fn root_counts_round_trip() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
assert_eq!(get_root_counts(&conn, "/tree"), None, "never counted");
set_root_counts(&conn, "/tree", RootCounts { files: 12, fts: 5 }).unwrap();
assert_eq!(
get_root_counts(&conn, "/tree"),
Some(RootCounts { files: 12, fts: 5 })
);
// Overwrite, not accumulate.
set_root_counts(&conn, "/tree", RootCounts { files: 20, fts: 9 }).unwrap();
assert_eq!(
get_root_counts(&conn, "/tree"),
Some(RootCounts { files: 20, fts: 9 })
);
// Roots do not read each other's figures.
assert_eq!(get_root_counts(&conn, "/other"), None);
// A value this build cannot parse reads as absent, like a missing one:
// the folder list says "not yet indexed" rather than showing a number
// that is a guess.
for bad in ["", "12", "12,", "a,b", "12,5,3"] {
conn.execute(
"INSERT OR REPLACE INTO schema_info(key, value) VALUES ('counts:/tree', ?1)",
params![bad],
)
.unwrap();
assert_eq!(get_root_counts(&conn, "/tree"), None, "parsed {:?}", bad);
}
drop(conn);
std::fs::remove_file(&p).ok();
}
/// Both kinds of per-root figure are swept together, so a root removed and
/// later re-added starts from neither a stale denominator nor a stale count.
#[test]
fn prune_root_stats_drops_every_figure_of_a_dropped_root() {
let p = tmp_path();
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
for root in ["/kept", "/dropped"] {
set_root_walk_count(&conn, root, 100).unwrap();
set_root_counts(&conn, root, RootCounts { files: 90, fts: 40 }).unwrap();
}
set_last_full_index(&conn, 1_700_000_000).unwrap();
prune_root_stats(&conn, &["/kept".to_string()]).unwrap();
assert_eq!(get_root_walk_count(&conn, "/kept"), Some(100));
assert_eq!(
get_root_counts(&conn, "/kept"),
Some(RootCounts { files: 90, fts: 40 })
);
assert_eq!(get_root_walk_count(&conn, "/dropped"), None);
assert_eq!(get_root_counts(&conn, "/dropped"), None);
// The sweep reads every `schema_info` key; unrelated ones must survive it.
assert_eq!(get_last_full_index(&conn), Some(1_700_000_000));
drop(conn);
std::fs::remove_file(&p).ok();
}

View file

@ -11,6 +11,58 @@ use crate::config::Config;
use crate::db::repo::{self};
use crate::indexing::should_abort;
/// The compressed sidecar for one row, or `None` where there is none to write
/// — an empty body, or `store_text_for_snippets` turned off.
///
/// `Err` is kept per row rather than failing the batch, because every caller
/// here already logs and skips a row whose write fails.
type Body = Result<Option<Vec<u8>>, String>;
/// Compress a batch's bodies through one context, before the caller takes the
/// connection.
///
/// Compression used to run inside the transaction, so a chunk's worth of it —
/// measured at ~8 ms per 500 documents — sat inside the `conn_mutex` hold as
/// pure CPU. Hoisting it here leaves the lock covering only the SQL, and
/// reusing one [`repo::DocEncoder`] across the batch cuts the compression
/// itself by ~4.7x (`benches/index.rs`, group `zstd_encode`).
///
/// What that lock does *not* gate, so the benefit is not overclaimed: search
/// holds its own connection (`db::open::open_search_reader`) and the database
/// is WAL, where a reader never blocks on a writer. `conn_mutex` serializes
/// the indexer against itself — one root's content stores against another's
/// walk inserts, the scope reconciler's slices, and WAL checkpointing. A
/// whole-tree wall-clock run is dominated by FTS5 trigram tokenization and
/// does not move measurably from this change; it is contention that improves,
/// not throughput.
fn compress_bodies<'a>(
texts: impl Iterator<Item = Option<&'a str>>,
config: &Config,
) -> Result<Vec<Body>, String> {
let mut enc = repo::DocEncoder::new()?;
Ok(texts
.map(|text| match text {
Some(t) if config.processing.store_text_for_snippets && !t.is_empty() => {
enc.encode(t).map(Some)
}
_ => Ok(None),
})
.collect())
}
/// The sidecar blob for row `i`, or a logged skip if its compression failed.
macro_rules! body_or_skip {
($bodies:expr, $i:expr, $what:expr) => {
match &$bodies[$i] {
Ok(b) => b.as_deref(),
Err(e) => {
crate::log_warn!("compress text for {}: {}", $what, e);
continue;
}
}
};
}
/// Write already-prepared records for files whose content changed.
///
/// The records arrive fully built (see [`prepare_file_record`]), so this does
@ -35,12 +87,14 @@ pub fn process_batch_updates(
return Ok(());
}
// Outside the lock — see `compress_bodies`.
let bodies = compress_bodies(batch.iter().map(|r| r.inline_text.as_deref()), config)?;
let conn = crate::lock_ok(conn_mutex);
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
for rec in batch.iter() {
for (i, rec) in batch.iter().enumerate() {
if stop_flag.load(Ordering::Relaxed) {
drop(tx);
drop(conn);
@ -70,7 +124,8 @@ pub fn process_batch_updates(
};
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
store_inline_text(&tx, id, rec, text, config)?;
let zstd = body_or_skip!(bodies, i, rec.path);
store_inline_text(&tx, id, rec, text, zstd)?;
}
}
@ -89,16 +144,9 @@ pub(crate) fn store_inline_text(
file_id: i64,
rec: &OwnedNewFile,
text: &str,
config: &Config,
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
repo::set_content_done(
tx,
file_id,
&rec.name,
text,
&[],
config.processing.store_text_for_snippets,
)
repo::set_content_done(tx, file_id, &rec.name, text, &[], text_zstd)
}
/// Write already-prepared records for newly discovered files. Silent, like
@ -119,12 +167,14 @@ pub fn process_batch_inserts(
return Ok(());
}
// Outside the lock — see `compress_bodies`.
let bodies = compress_bodies(batch.iter().map(|r| r.inline_text.as_deref()), config)?;
let conn = crate::lock_ok(conn_mutex);
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
for rec in batch.iter() {
for (i, rec) in batch.iter().enumerate() {
if stop_flag.load(Ordering::Relaxed) {
drop(tx);
drop(conn);
@ -133,7 +183,8 @@ pub fn process_batch_inserts(
let id = repo::insert_file(&tx, &rec.as_new_file())
.map_err(|e| format!("Failed to insert file record: {}", e))?;
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
store_inline_text(&tx, id, rec, text, config)?;
let zstd = body_or_skip!(bodies, i, rec.path);
store_inline_text(&tx, id, rec, text, zstd)?;
}
}
@ -307,13 +358,20 @@ pub fn store_extracted(
if stop_flag.load(Ordering::Relaxed) {
return Ok(written);
}
// Outside the lock — see `compress_bodies`.
let bodies = compress_bodies(
batch
.iter()
.map(|r| crate::file_handling::outcome_body(&r.outcome)),
config,
)?;
let conn = crate::lock_ok(conn_mutex);
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
for row in batch {
if let Err(e) = store_content_outcome(&tx, row.file_id, &row.name, &row.outcome, config)
{
for (i, row) in batch.iter().enumerate() {
let zstd = body_or_skip!(bodies, i, row.name);
if let Err(e) = store_content_outcome(&tx, row.file_id, &row.name, &row.outcome, zstd) {
crate::log_warn!("content indexing for {}: {}", row.name, e);
continue;
}

View file

@ -11,17 +11,17 @@ mod count_and_extract_tests;
#[cfg(test)]
mod tests;
pub(crate) use batch::store_inline_text;
pub use batch::{
cleanup_stale_index_entries, extract_scope_prepare, process_batch_inserts,
process_batch_updates, store_extracted, ExtractCursor, ExtractScope,
};
pub(crate) use batch::store_inline_text;
pub use counting::count_tree_entries_fast;
pub use paths::{db_key_for_missing_path, filtered_dirs, filtered_walk, UnreadableDirs};
pub(crate) use paths::{normalize_root_string, path_to_db_string, warn_if_unrepresentable};
pub use records::{
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
extract_and_store, fts_finalize_after_text_indexing, hash_failure_counts,
prepare_file_record, prepare_file_record_from_path, reset_run_warnings,
store_content_outcome, ContentOutcome, DirRows, FileIndexAction, OwnedNewFile,
extract_and_store, fts_finalize_after_text_indexing, hash_failure_counts, outcome_body,
prepare_file_record, prepare_file_record_from_path, reset_run_warnings, store_content_outcome,
ContentOutcome, DirRows, FileIndexAction, OwnedNewFile,
};

View file

@ -302,7 +302,11 @@ pub fn extract_and_store(
config: &Config,
) -> Result<(), String> {
let outcome = decide_content(path, mime, registry, config);
store_content_outcome(tx, file_id, name, &outcome, config)
let zstd = match outcome_body(&outcome) {
Some(text) => repo::encode_one(text, config.processing.store_text_for_snippets)?,
None => None,
};
store_content_outcome(tx, file_id, name, &outcome, zstd.as_deref())
}
/// What should be written for one file's content, decided without touching
@ -375,23 +379,30 @@ pub fn decide_content(
/// Apply a decision from [`decide_content`]. The cheap half: pure database
/// writes, so this is all that runs with the connection held.
///
/// `text_zstd` is the compressed body for a `Done` outcome, prepared by the
/// caller before it took the lock — see [`repo::set_content_done`].
pub fn store_content_outcome(
tx: &rusqlite::Transaction<'_>,
file_id: i64,
name: &str,
outcome: &ContentOutcome,
config: &Config,
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
match outcome {
ContentOutcome::Done { text, properties } => repo::set_content_done(
tx,
file_id,
name,
text,
properties,
config.processing.store_text_for_snippets,
),
ContentOutcome::Done { text, properties } => {
repo::set_content_done(tx, file_id, name, text, properties, text_zstd)
}
ContentOutcome::NotApplicable => repo::set_content_na(tx, file_id),
ContentOutcome::Failed(reason) => repo::set_content_failed(tx, file_id, reason),
}
}
/// The body a [`ContentOutcome`] would store, if any — what the caller feeds
/// to its [`repo::DocEncoder`] ahead of the lock.
pub fn outcome_body(outcome: &ContentOutcome) -> Option<&str> {
match outcome {
ContentOutcome::Done { text, .. } => Some(text),
_ => None,
}
}

View file

@ -137,7 +137,8 @@ fn upsert_file(
repo::set_content_na(&tx, file_id)?;
} else if let Some(text) = rec.inline_text.as_deref() {
// `prepare_file_record_from_path` already read the whole file.
store_inline_text(&tx, file_id, &rec, text, config)?;
let zstd = repo::encode_one(text, config.processing.store_text_for_snippets)?;
store_inline_text(&tx, file_id, &rec, text, zstd.as_deref())?;
} else {
extract_and_store(
&tx,

View file

@ -655,7 +655,7 @@ impl IndexingService {
// compete for the connection.
let stored_counts: Vec<Option<usize>> = {
let conn = crate::lock_ok(&cx.conn_mutex);
let _ = crate::db::repo::prune_root_walk_counts(&conn, &roots);
let _ = crate::db::repo::prune_root_stats(&conn, &roots);
roots
.iter()
.map(|r| crate::db::repo::get_root_walk_count(&conn, r))
@ -805,6 +805,26 @@ impl IndexingService {
crate::log_warn!("{}", e);
}
// What each root holds, for the folder list to show once these
// pipelines and their `RootProgress` rows are gone. Here rather than on
// a cadence: `count_root` reads every row in the range, and this run
// has just written them, so the pages are as warm as they will ever be.
// Under the interrupt guard because it is still a scan per root, and
// quitting should not wait out one of them; a root whose count fails
// keeps the figure it had.
let _guard = db::InterruptGuard::arm(interrupt, &conn);
for root in &roots {
let range = ExtractCursor::for_root(root);
match repo::count_root(&conn, &range.lo, &range.hi) {
Ok(counts) => {
if let Err(e) = repo::set_root_counts(&conn, root, counts) {
crate::log_warn!("{}", e);
}
}
Err(e) => crate::log_warn!("counts for {} unavailable: {}", root, e),
}
}
Ok(())
}
}

View file

@ -163,6 +163,12 @@ fn stored_walk_count(db_path: &str, root: &str) -> Option<usize> {
crate::db::repo::get_root_walk_count(&conn, root)
}
/// What the last completed run counted under `root`, if any.
fn stored_root_counts(db_path: &str, root: &str) -> Option<crate::db::repo::RootCounts> {
let conn = db::open_existing(db_path, false).unwrap();
crate::db::repo::get_root_counts(&conn, root)
}
/// A walk that could not read part of its tree must not record its count:
/// the figure is the next run's progress denominator, and nothing ever
/// re-derives it.
@ -234,6 +240,92 @@ fn a_stopped_run_keeps_the_walk_count_unrecorded() {
std::fs::remove_dir_all(&dir).ok();
}
/// A completed run records what each root holds, so the folder list can show
/// it once the run's own per-root progress rows are gone.
///
/// The extension whitelist is narrowed to `txt` so the split between the two
/// figures is the test's to decide rather than the default list's.
#[test]
fn a_completed_run_records_what_each_root_holds() {
let dir = tmp_dir("root-counts");
// A subdirectory, so the index and its WAL sidecars are not themselves
// files under the root being counted.
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("a.txt"), "alpha body").unwrap();
std::fs::write(tree.join("b.txt"), "beta body").unwrap();
std::fs::write(tree.join("c.log"), "outside the whitelist").unwrap();
let db_path = dir.join("index.db").to_string_lossy().into_owned();
let mut config = Config::default();
config.paths.indexing_paths = vec![tree.to_string_lossy().into_owned()];
config.paths.database_path = db_path.clone();
config.indexing.content_extensions = vec!["txt".into()];
let root = normalize_root_string(&tree.to_string_lossy());
run_with(&config, &db_path, &Arc::new(AtomicBool::new(false))).unwrap();
let stored = stored_root_counts(&db_path, &root).expect("a completed run records them");
assert_eq!(
stored,
crate::db::repo::RootCounts { files: 3, fts: 2 },
"every file under the root, and the two the whitelist let through"
);
// And they describe the index rather than the walk: the same two numbers
// read straight off the tables the folder list is standing in for.
let conn = db::open_existing(&db_path, false).unwrap();
let files: i64 = conn
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
let fts: i64 = conn
.query_row("SELECT COUNT(*) FROM searchabletext", [], |r| r.get(0))
.unwrap();
assert_eq!((stored.files, stored.fts), (files, fts));
drop(conn);
std::fs::remove_dir_all(&dir).ok();
}
/// A stopped run counted part of a tree it was still changing, so the figures
/// it would store are worse than the ones already there. Pinned in both
/// directions: a guard that simply never stored anything would satisfy the
/// negative half on its own.
#[test]
fn a_stopped_run_keeps_the_recorded_counts() {
let dir = tmp_dir("stopped-root-counts");
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("a.txt"), "alpha body").unwrap();
let db_path = dir.join("index.db").to_string_lossy().into_owned();
let mut config = Config::default();
config.paths.indexing_paths = vec![tree.to_string_lossy().into_owned()];
config.paths.database_path = db_path.clone();
let root = normalize_root_string(&tree.to_string_lossy());
run_with(&config, &db_path, &Arc::new(AtomicBool::new(false))).unwrap();
let after_run = stored_root_counts(&db_path, &root).expect("recorded");
assert_eq!(after_run.files, 1);
// Two more files, then a run that stops at its first check — the
// deterministic stand-in for a shutdown part-way through.
std::fs::write(tree.join("b.txt"), "beta body").unwrap();
std::fs::write(tree.join("c.txt"), "gamma body").unwrap();
run_with(&config, &db_path, &Arc::new(AtomicBool::new(true))).unwrap();
assert_eq!(
stored_root_counts(&db_path, &root),
Some(after_run),
"a stopped run leaves the last completed run's figures alone"
);
// The same tree, run to completion, does move them.
run_with(&config, &db_path, &Arc::new(AtomicBool::new(false))).unwrap();
assert_eq!(stored_root_counts(&db_path, &root).unwrap().files, 3);
std::fs::remove_dir_all(&dir).ok();
}
/// A reconcile the stop flag cut short must leave the stored fingerprint
/// alone: stamping it would tell every later run the index already matches,
/// and nothing would ever revisit the rows the scan had not reached.

View file

@ -211,6 +211,32 @@ impl TermPattern {
}
}
/// ASCII-case-insensitive [`str::find`], without folding the haystack.
///
/// `needle` must already be `to_ascii_lowercase`d — `LiteralPattern`
/// stores it that way. This exists because the folding version allocated
/// a lowercase copy of its haystack on every call, and the cascade's
/// filename pass calls it twice — once on the name, once on the path —
/// for every row of a full-table scan.
///
/// The candidate index is always a `char` boundary: a `&str`'s first byte
/// is either ASCII or a UTF-8 lead byte, never a continuation byte, so a
/// first-byte hit cannot land mid-character. Both sides being valid UTF-8
/// that differ only in ASCII case then makes the end a boundary too, which
/// is what keeps the returned range valid in the unfolded original.
fn find_ascii_ci(hay: &str, needle: &str) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
let (h, n) = (hay.as_bytes(), needle.as_bytes());
let first = n[0];
h.len().checked_sub(n.len()).and_then(|last| {
(0..=last).find(|&i| {
h[i].to_ascii_lowercase() == first && h[i..i + n.len()].eq_ignore_ascii_case(n)
})
})
}
/// Leftmost match as a byte range. Literal folding is ASCII-only and
/// byte-length preserving, so folded offsets are valid in the original —
/// the same invariant the cascade has always relied on.
@ -219,7 +245,7 @@ impl TermPattern {
TermPattern::Empty => None,
TermPattern::Literal(l) => {
let pos = if case_insensitive {
text.to_ascii_lowercase().find(&l.folded)?
Self::find_ascii_ci(text, &l.folded)?
} else {
text.find(&l.text)?
};

View file

@ -10,6 +10,92 @@ enum RowHit {
Defer(SearchHit),
}
/// Reusable decode buffer and decompression context for the passes that read
/// document text.
///
/// `zstd::decode_all` builds and tears down a `ZSTD_DCtx` *and* allocates a
/// fresh output `Vec` on every call, and it is called once per candidate row.
/// One context and one buffer, reused across a whole scan, make that a
/// per-scan cost instead of a per-row one.
struct DocDecoder {
dctx: zstd::bulk::Decompressor<'static>,
buf: Vec<u8>,
}
/// Where [`DocDecoder::decode`]'s buffer starts before it has seen a document.
/// Most extracted text is well under this, so the doubling below rarely runs.
const INITIAL_DOC_CAPACITY: usize = 64 * 1024;
/// Where the doubling stops. Stored text is capped at
/// `processing.maximum_text_size` (256 KiB by default), so this is far above
/// any legitimate document even if that setting is raised — past it, a failure
/// is a corrupt frame rather than a buffer that is too small.
const MAX_DOC_CAPACITY: usize = 64 * 1024 * 1024;
impl DocDecoder {
fn new() -> Result<Self, String> {
Ok(DocDecoder {
dctx: zstd::bulk::Decompressor::new().map_err(|e| e.to_string())?,
buf: Vec::new(),
})
}
/// Decompress `blob` and borrow the result as text.
///
/// Returns `None` for a corrupt frame or non-UTF-8 content. Nothing is
/// copied: the indexer stores UTF-8, so the bytes are borrowed in place
/// rather than run through `String::from_utf8_lossy(..).into_owned()`,
/// which duplicated the whole document even when it was already valid.
fn decode(&mut self, blob: &[u8]) -> Option<&str> {
self.buf.clear();
// `decompress_to_buffer` writes into spare capacity and fails rather
// than growing, so the room has to be there first.
//
// The frame header would say how much is needed, but the indexer
// writes with `zstd::encode_all`, which is *stream*-based and so
// records no content size — `get_frame_content_size` says `None` for
// every row this ever sees. Falling back to `zstd::decode_all` there
// looked harmless and was not: it builds a streaming decoder per call,
// which measured as one ~2.4 MiB allocation per document and 27 of the
// 30 GiB a fuzzy search moved through the allocator.
//
// So grow this buffer instead and keep reusing it. It settles at the
// largest document in the scan within the first few rows, after which
// decoding a row allocates nothing at all.
if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) {
self.buf.reserve(usize::try_from(size).ok()?);
}
loop {
if self.buf.capacity() == 0 {
self.buf.reserve(INITIAL_DOC_CAPACITY);
}
match self.dctx.decompress_to_buffer(blob, &mut self.buf) {
Ok(_) => break,
// Too small, or corrupt — the bulk API cannot tell us which.
// Growing is only worth trying while the buffer is still
// smaller than any document could legitimately be.
Err(_) if self.buf.capacity() < MAX_DOC_CAPACITY => {
let bigger = self.buf.capacity().saturating_mul(2);
self.buf.clear();
self.buf.reserve(bigger);
}
Err(_) => return None,
}
}
std::str::from_utf8(&self.buf).ok()
}
}
/// Fold `text` into `dst` in place, reusing its allocation.
///
/// The ASCII fold is byte-length preserving, which is what lets the cascade
/// use offsets found in the folded copy against the unfolded original.
fn fold_into(dst: &mut String, text: &str) {
dst.clear();
dst.push_str(text);
dst.make_ascii_lowercase();
}
/// Which [`Deferred`] buffer a scan's held-back hits go to.
enum DeferSlot {
/// Ranks 910, flushed by [`Pass::Path`]. Shared by passes A and E,
@ -39,7 +125,10 @@ impl<'a> Cx<'a> {
mut classify: impl FnMut(&mut Self, &rusqlite::Row<'_>, i64, &str) -> Result<RowHit, String>,
) -> Result<bool, String> {
let conn = self.conn;
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
// Cached: a search re-runs the same six statements on every keystroke,
// and only the bound term changes between them — the filter SQL each
// one interpolates is fixed for the life of the query.
let mut stmt = conn.prepare_cached(sql).map_err(|e| e.to_string())?;
let mut rows = stmt
.query(rusqlite::params_from_iter(params))
.map_err(|e| e.to_string())?;
@ -55,11 +144,18 @@ impl<'a> Cx<'a> {
return Ok(false);
}
let file_id: i64 = col(row, 0)?;
let path: String = col(row, 2)?;
if self.skip(file_id, &path) {
// Borrowed from the statement rather than `col::<String>`: this
// runs for every *scanned* row — a full-table scan on the filename
// pass — while only the few that become hits need an owned copy.
let path = row
.get_ref(2)
.map_err(|e| e.to_string())?
.as_str()
.map_err(|e| e.to_string())?;
if self.skip(file_id, path) {
continue;
}
match classify(self, row, file_id, &path)? {
match classify(self, row, file_id, path)? {
RowHit::Skip => {}
RowHit::Emit(hit) => {
buf.push(hit);
@ -234,26 +330,33 @@ impl<'a> Cx<'a> {
let snippet_opts = snippet::Options {
approx_chars: SNIPPET_WINDOW_CHARS,
};
// One decoder and one fold buffer for the whole scan; both are reused
// per row rather than reallocated.
let mut doc = DocDecoder::new()?;
let mut lower = String::new();
// Decompression dominates: check cancellation every row.
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| {
let blob: Option<Vec<u8>> = col(row, 5)?;
let text = blob
.and_then(|b| zstd::decode_all(b.as_slice()).ok())
.map(|raw| String::from_utf8_lossy(&raw).into_owned());
let blob: Option<&[u8]> = row
.get_ref(5)
.map_err(|e| e.to_string())?
.as_blob_or_null()
.map_err(|e| e.to_string())?;
let text = blob.and_then(|b| doc.decode(b));
let (rank, stage, snip) = match &text {
let (rank, stage, snip) = match text {
Some(text) => {
// Fold once: the case-insensitive count, the first-match
// search and the snippet extraction all need it, and
// nearly every candidate takes this path.
let mut folded: Option<String> = None;
let mut folded = false;
let (count, stage) = {
let count_cs = pattern.count(text, false);
if count_cs > 0 {
(count_cs, 5)
} else {
let lower = folded.insert(text.to_ascii_lowercase());
let count_ci = pattern.count_folded(lower);
fold_into(&mut lower, text);
folded = true;
let count_ci = pattern.count_folded(&lower);
if count_ci > 0 {
(count_ci, 6)
} else {
@ -263,9 +366,11 @@ impl<'a> Cx<'a> {
}
}
};
if !folded {
fold_into(&mut lower, text);
}
// Literal terms keep the richer multi-occurrence
// extract; a wildcard match marks its own first range.
let lower = folded.unwrap_or_else(|| text.to_ascii_lowercase());
let snip = match pattern.literal() {
Some(term) => Some(snippet::extract_folded(
text,
@ -291,7 +396,7 @@ impl<'a> Cx<'a> {
(6.0 + count_frac(1), 6, None)
}
};
if !cx.regex_accepts(file_id, path, text.as_deref())? {
if !cx.regex_accepts(file_id, path, text)? {
return Ok(RowHit::Skip);
}
@ -416,27 +521,30 @@ impl<'a> Cx<'a> {
let snippet_opts = snippet::Options {
approx_chars: SNIPPET_WINDOW_CHARS,
};
// One decoder and one fold buffer for the whole scan, reused per row.
let mut doc = DocDecoder::new()?;
let mut folded = String::new();
// Decompression dominates: check cancellation every row.
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| {
let blob: Option<Vec<u8>> = col(row, 5)?;
let Some(blob) = blob else {
let blob: Option<&[u8]> = row
.get_ref(5)
.map_err(|e| e.to_string())?
.as_blob_or_null()
.map_err(|e| e.to_string())?;
let Some(text) = blob.and_then(|b| doc.decode(b)) else {
return Ok(RowHit::Skip);
};
let Ok(raw) = zstd::decode_all(blob.as_slice()) else {
return Ok(RowHit::Skip);
};
let text = String::from_utf8_lossy(&raw).into_owned();
// ASCII folding is byte-length preserving, so ranges found in
// the folded buffer are valid in the original.
let folded = text.to_ascii_lowercase();
fold_into(&mut folded, text);
let (count, first) = bitap.count_and_first(folded.as_bytes());
if count == 0 {
return Ok(RowHit::Skip);
}
if !cx.regex_accepts(file_id, path, Some(&text))? {
if !cx.regex_accepts(file_id, path, Some(text))? {
return Ok(RowHit::Skip);
}
let snip = first.map(|range| snippet::window_around(&text, range, &snippet_opts));
let snip = first.map(|range| snippet::window_around(text, range, &snippet_opts));
let (size, mtime) = size_and_mtime(row)?;
Ok(RowHit::Emit(SearchHit {
file_id,
@ -522,22 +630,27 @@ impl<'a> Cx<'a> {
let snippet_opts = snippet::Options {
approx_chars: SNIPPET_WINDOW_CHARS,
};
// One decoder for the whole scan, reused per row.
let mut doc = DocDecoder::new()?;
// Decompression dominates: check cancellation every row.
self.scan_pass(&sql, params, 1, None, |_cx, row, file_id, path| {
let blob: Option<Vec<u8>> = col(row, 5)?;
let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else {
let blob: Option<&[u8]> = row
.get_ref(5)
.map_err(|e| e.to_string())?
.as_blob_or_null()
.map_err(|e| e.to_string())?;
let Some(text) = blob.and_then(|b| doc.decode(b)) else {
return Ok(RowHit::Skip);
};
let text = String::from_utf8_lossy(&raw).into_owned();
let count = re.count(&text);
let count = re.count(text);
if count == 0 {
return Ok(RowHit::Skip);
}
// A greedy user regex can match megabytes; clamp the range
// before the snippet window is cut.
let snip = re.find_first(&text).map(|r| {
let r = clamp_match_range(&text, r, SNIPPET_WINDOW_CHARS);
snippet::window_around(&text, (r.start, r.end), &snippet_opts)
let snip = re.find_first(text).map(|r| {
let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS);
snippet::window_around(text, (r.start, r.end), &snippet_opts)
});
let (size, mtime) = size_and_mtime(row)?;
Ok(RowHit::Emit(SearchHit {

View file

@ -298,7 +298,20 @@ impl Worker {
let first = match self.req_rx.recv_timeout(self.idle_release) {
Ok(req) => req,
Err(mpsc::RecvTimeoutError::Timeout) => {
self.open = None;
// Dropping the connection frees `PRAGMAS_SEARCH`'s 32 MiB
// page cache to glibc, which parks it in an arena rather
// than returning it to the kernel. Without the trim a
// single typing session raises the process floor by ~42 MiB
// for as long as it runs — measured on a 77k-file index,
// where an idle GUI sat at 76 MiB `RssAnon` instead of 34.
// Gated on there having *been* a connection so this is once
// per session→idle transition, never a repeating tick:
// `malloc_trim` walks every arena and costs milliseconds.
// The coordinator's writer settles the same way in
// `go_idle`.
if self.open.take().is_some() {
crate::platform::release_free_heap();
}
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => return,

View file

@ -76,13 +76,16 @@ pub fn extract_folded(text: &str, folded: &str, terms: &[&str], opts: &Options)
return head_window(text, opts.approx_chars);
}
// `memmem` rather than `str::match_indices`: both find non-overlapping
// occurrences, but std's Two-Way searcher has no vector prefilter and a
// full-text row scans a whole document body. See `benches/search.rs`,
// group `substring`.
let mut matches: Vec<(usize, usize)> = Vec::new();
for term in &effective_terms {
let pattern = term.to_ascii_lowercase();
matches.extend(
folded
.match_indices(&pattern)
.map(|(at, _)| (at, at + pattern.len())),
memchr::memmem::find_iter(folded.as_bytes(), pattern.as_bytes())
.map(|at| (at, at + pattern.len())),
);
}
@ -185,11 +188,13 @@ pub fn count_occurrences(text: &str, term: &str, case_sensitive: bool) -> usize
return 0;
}
if case_sensitive {
text.match_indices(term).count()
memchr::memmem::find_iter(text.as_bytes(), term.as_bytes()).count()
} else {
text.to_ascii_lowercase()
.match_indices(&term.to_ascii_lowercase())
.count()
memchr::memmem::find_iter(
text.to_ascii_lowercase().as_bytes(),
term.to_ascii_lowercase().as_bytes(),
)
.count()
}
}

View file

@ -11,6 +11,16 @@ use std::sync::atomic::{AtomicUsize, Ordering};
/// lets two tests in the same millisecond collide.
static NEXT: AtomicUsize = AtomicUsize::new(0);
/// The compressed body [`crate::db::repo::set_content_done`] wants, for tests
/// that only care that a sidecar row gets written.
///
/// Production callers compress a whole batch through one
/// [`crate::db::repo::DocEncoder`] before taking the connection lock; a test
/// writing one row has nothing to amortize and wants the one-liner.
pub fn zstd_of(text: &str) -> Option<Vec<u8>> {
crate::db::repo::encode_one(text, true).expect("zstd encode")
}
/// A fresh, empty directory under the system temp dir, named for `tag`.
/// Not cleaned up on drop: when a test fails, the tree it built is most of
/// the evidence. Panics — a test that cannot create a directory has nothing

View file

@ -10,6 +10,7 @@ use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile};
use quicksearch_core::mime::FileType;
use quicksearch_core::query::split::split_for_cascade;
use quicksearch_core::search::{cascade, SearchHit, SearchOptions, SearchService, SearchUpdate};
use quicksearch_core::testutil::zstd_of;
mod common;
use common::scratch_db as tmp_db;
@ -50,7 +51,8 @@ impl Seeder {
.unwrap()
.expect("unique path");
if let Some(text) = text {
set_content_done(&tx, id, name, text, &[], self.store_text).unwrap();
let zstd = self.store_text.then(|| zstd_of(text)).flatten();
set_content_done(&tx, id, name, text, &[], zstd.as_deref()).unwrap();
}
tx.commit().unwrap();
id

View file

@ -35,6 +35,7 @@ use quicksearch_core::mime::FileType;
use quicksearch_core::query::split::split_for_cascade;
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
use quicksearch_core::security::IndexKey;
use quicksearch_core::testutil::zstd_of;
use rusqlite::Connection;
mod common;
@ -137,7 +138,8 @@ fn seed(path: &std::path::Path) {
let body: Vec<&str> = (0..60)
.map(|_| WORDS[(rng.next() as usize) % WORDS.len()])
.collect();
set_content_done(&tx, id, &name, &body.join(" "), &[], true).unwrap();
let body = body.join(" ");
set_content_done(&tx, id, &name, &body, &[], zstd_of(&body).as_deref()).unwrap();
}
}
tx.commit().unwrap();
@ -203,6 +205,15 @@ fn run_matrix(label: &str, path: &std::path::Path) {
}
}
/// Serializes the two matrices below.
///
/// `set_process_key` is process-global, and libtest runs `#[test]` functions
/// on concurrent threads within one process — so without this the encrypted
/// run's key is set while the unencrypted run opens its plain index, and that
/// open fails with `NotADatabase`. Being separate tests is not enough on its
/// own; they have to not overlap.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The headline comparison, printed rather than asserted.
///
/// Deliberately not a pass/fail threshold: timings on a shared CI box are not
@ -215,6 +226,7 @@ fn cache_size_against_search_latency() {
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
return;
}
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let plain = scratch_db("searchperf-plain");
let seeded = Instant::now();
@ -232,13 +244,14 @@ fn cache_size_against_search_latency() {
///
/// Separate test, and separate process-wide key, because
/// [`set_process_key`] is global: running both in one test would have the
/// plain index opened with a key set.
/// plain index opened with a key set. [`SERIAL`] keeps them from overlapping.
#[test]
fn cache_size_against_search_latency_encrypted() {
if !enabled() {
eprintln!("skipping: set QSB_SEARCH_PERF=1 to run");
return;
}
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
set_process_key(Some(
IndexKey::from_hex(&"42".repeat(32)).expect("valid 32-byte key"),

View file

@ -26,6 +26,16 @@ pub struct LogsTab {
/// (egui unsticks a scroll area the user moves, and re-sticks it when
/// they return to the bottom); unticking this stops it following at all.
follow: bool,
/// Indices into `lines` that pass both filters.
///
/// Cached rather than rebuilt per frame: `keep` lowercases each line to
/// compare it, so with a filter typed this was up to [`log::CAPACITY`]
/// (5,000) string allocations every frame, on a tab that repaints twice a
/// second by itself and on every input frame besides.
shown: Vec<usize>,
/// What `shown` was computed from — refresh counter, filter text,
/// warnings-only — so it can be rebuilt exactly when one of them moves.
shown_key: (u64, String, bool),
}
impl LogsTab {
@ -37,9 +47,36 @@ impl LogsTab {
filter: String::new(),
warnings_only: false,
follow: true,
shown: Vec::new(),
// `u64::MAX` so the first frame always counts as stale, whatever
// the ring's counter happens to be.
shown_key: (u64::MAX, String::new(), false),
}
}
/// Rebuild [`LogsTab::shown`] if any of its inputs moved.
fn resync_shown(&mut self) {
if self.shown_key.0 == self.seen
&& self.shown_key.2 == self.warnings_only
&& self.shown_key.1 == self.filter
{
return;
}
let needle = self.filter.to_lowercase();
let warnings_only = self.warnings_only;
let mut shown = std::mem::take(&mut self.shown);
shown.clear();
shown.extend(
self.lines
.iter()
.enumerate()
.filter(|(_, l)| keep(l, &needle, warnings_only))
.map(|(i, _)| i),
);
self.shown = shown;
self.shown_key = (self.seen, self.filter.clone(), warnings_only);
}
fn refresh(&mut self) {
self.lines = log::snapshot();
self.seen = log::recorded();
@ -53,14 +90,11 @@ impl LogsTab {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(REFRESH_MS));
let needle = self.filter.to_lowercase();
let shown: Vec<usize> = self
.lines
.iter()
.enumerate()
.filter(|(_, l)| keep(l, &needle, self.warnings_only))
.map(|(i, _)| i)
.collect();
// The filter and warnings-only widgets are drawn below, so a change
// lands on the next frame — the same one-frame lag this always had,
// and the tab repaints immediately anyway.
self.resync_shown();
let shown = std::mem::take(&mut self.shown);
let mut cleared = false;
ui.horizontal(|ui| {
@ -106,7 +140,10 @@ impl LogsTab {
});
});
if cleared {
// `shown` indexes lines that no longer exist.
// `shown` indexes lines that no longer exist. Handing the buffer
// back keeps its capacity; `refresh` moves `seen`, which is what
// makes the next frame rebuild the contents.
self.shown = shown;
self.refresh();
return;
}
@ -131,10 +168,12 @@ impl LogsTab {
)
.weak(),
);
self.shown = shown;
return;
}
if shown.is_empty() {
ui.label(egui::RichText::new("No lines match the filter.").weak());
self.shown = shown;
return;
}
@ -163,6 +202,7 @@ impl LogsTab {
}
});
crate::ui_util::more_below_hint(ui, &scroll);
self.shown = shown;
}
}

View file

@ -295,6 +295,15 @@ impl ManageTab {
}
ui.label(hint("workers:"));
// Unconditional, placeholder and all: egui names a
// widget by how many precede it, so a label that
// came and went would rename the field above and
// cost it any edit in progress. After that field
// for the same reason — in this right-to-left
// layout "after" is to its left.
ui.label(hint(root_counts_text(state, root)))
.tip(&tips::ROOT_COUNTS);
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
@ -549,6 +558,24 @@ fn db_size_tooltip(ui: &mut egui::Ui) {
));
}
/// What `root` held when indexing last completed, worded as the live
/// per-root rows word it (see [`root_row`]) so the list does not rename the
/// same two figures once the run that produced them is over.
///
/// A root the coordinator has no figures for — never indexed to completion,
/// staged in the draft but not yet applied, or an index that was cleared —
/// says so rather than claiming zero.
fn root_counts_text(state: &IndexerState, root: &str) -> String {
match state.root_counts.iter().find(|c| c.root == root) {
Some(c) => format!(
"indexed {} · extracted {}",
group_thousands(c.counts.files.max(0) as u64),
group_thousands(c.counts.fts.max(0) as u64)
),
None => "not yet indexed".to_string(),
}
}
/// Append a root to the draft unless it would duplicate or nest with an
/// existing one; the rejection reason lands in `error`.
fn try_add_root(draft: &mut Config, candidate: String, error: &mut Option<String>) -> bool {

View file

@ -1,6 +1,10 @@
use super::*;
use std::cell::RefCell;
use std::sync::Arc;
use quicksearch_core::coordinator::RootCount;
use quicksearch_core::db::repo::RootCounts;
// The widgets under test report themselves here so their identity can be
// checked across frames.
@ -32,6 +36,19 @@ fn idle_state() -> IndexerState {
queued_events: 0,
watcher: WatcherStatus::Active { dirs: 10 },
reconcile: None,
root_counts: Arc::new(Vec::new()),
}
}
/// An idle state carrying figures for `/data`, the root `cfg_with_root`
/// configures.
fn counted_state(files: i64, fts: i64) -> IndexerState {
IndexerState {
root_counts: Arc::new(vec![RootCount {
root: "/data".into(),
counts: RootCounts { files, fts },
}]),
..idle_state()
}
}
@ -171,6 +188,9 @@ fn the_worker_field_keeps_its_identity_as_the_status_changes() {
running_state(&["/data"], Some("/data/file")),
running_state(&["/data", "/other"], None),
idle_state(),
// The per-root figures appear and disappear on the same row as the
// field, which is the case a conditionally-drawn label would break.
counted_state(1_234_567, 456_789),
IndexerState {
watcher: WatcherStatus::Off,
..idle_state()
@ -315,6 +335,61 @@ fn a_finished_root_reports_its_exact_count_not_the_estimate() {
);
}
/// The folder list carries the last completed run's figures, so what a root
/// holds survives the run that counted it.
#[test]
fn a_configured_root_shows_what_the_last_run_counted() {
let ctx = egui::Context::default();
let mut tab = ManageTab::new();
let text = frame_text(&ctx, &mut tab, &counted_state(1_234_567, 456_789)).join(" | ");
assert!(
text.contains("indexed 1,234,567 · extracted 456,789"),
"folder row: {}",
text
);
}
/// A root with no stored figures says so. Zero would be a claim — that the
/// folder is empty — where the truth is that nothing has counted it yet.
#[test]
fn a_root_the_index_has_never_counted_says_so() {
let ctx = egui::Context::default();
let mut tab = ManageTab::new();
let text = frame_text(&ctx, &mut tab, &idle_state()).join(" | ");
assert!(text.contains("not yet indexed"), "folder row: {}", text);
assert!(
!text.contains("indexed 0 · extracted 0"),
"an uncounted root must not read as an empty one: {}",
text
);
}
/// Figures are matched to the root by the spelling the config uses, so a
/// root the coordinator has not published anything for keeps the placeholder
/// rather than borrowing another root's numbers.
#[test]
fn figures_belong_to_the_root_they_were_counted_for() {
let ctx = egui::Context::default();
let mut tab = ManageTab::new();
let state = IndexerState {
root_counts: Arc::new(vec![RootCount {
root: "/somewhere-else".into(),
counts: RootCounts { files: 99, fts: 9 },
}]),
..idle_state()
};
let text = frame_text(&ctx, &mut tab, &state).join(" | ");
assert!(text.contains("not yet indexed"), "folder row: {}", text);
assert!(
!text.contains("99"),
"borrowed another root's count: {}",
text
);
}
/// The estimate is shown while the walk runs — but never below what has
/// already been walked, or the row would read as a hang at 100%.
#[test]

View file

@ -517,6 +517,20 @@ pub static ROOT_WORKERS: Tip = Tip {
caution: None,
};
pub static ROOT_COUNTS: Tip = Tip {
title: "What this folder holds",
body: "How many files under this folder are in the index, and how many of \
those had their text extracted and so can be found by their \
contents rather than only by name. The gap between the two is the \
files nothing could read text from (images, videos, archives, \
program binaries) plus anything the extension whitelist \
excludes.\n\n\
Both are counted when an indexing run finishes, so they do not \
move as live updates apply single changes in between.",
examples: &[],
caution: None,
};
// --- Manage Index tab: content filters -----------------------------------
pub static EXT_WHITELIST: Tip = Tip {
@ -610,6 +624,7 @@ mod tests {
&ADD_ROOT,
&REMOVE_ROOT,
&ROOT_WORKERS,
&ROOT_COUNTS,
&EXT_WHITELIST,
&IGNORE_PATTERNS,
&APPLY_SAVE,