Tail-check duplicates, re-measure benchmarks and fix CI's release-only test failures
All checks were successful
CI / linux (push) Successful in 16m22s
CI / windows-cross (push) Successful in 5m9s
CI / release (push) Successful in 15s

- Duplicates tab: groups are confirmed by each file's last 8 KiB and
  keyed by head+tail; unchecked files are never shown. False-positive
  duplicates drop from 3.4% to 0.1%, and indexing is unchanged.
- Encrypted search cache cap raised to 192 MiB at 150 B per file: a
  keystroke at 1M files takes 59 ms, down from 99 ms.
- Case-insensitive filename matching uses a memchr2 scan (1.18x).
- Benches run again on the compact index, seed with the indexer's 2 GiB
  WAL cap (peak scratch 6.2 GB -> 2.45 GB), and every recorded figure
  is re-measured.
- A fresh index no longer logs a rebuild warning.
- CI: the sandbox memory test's allocation survives release builds, and
  --toggle retries an interrupted read.
This commit is contained in:
Jeremy Karst 2026-10-12 00:14:10 -04:00
parent 285287926b
commit be30ddc9af
37 changed files with 1687 additions and 414 deletions

View file

@ -101,13 +101,19 @@ readable with the old password.
changes again. Sub-second times would cost a rebuild and gain nothing on changes again. Sub-second times would cost a rebuild and gain nothing on
FAT, which keeps two-second times, and the size already catches appends FAT, which keeps two-second times, and the size already catches appends
and most replacements. and most replacements.
- Duplicate groups are a strong suspicion, not a certainty: grouping hashes - Duplicate groups are a strong suspicion, not a certainty: indexing hashes
only each file's size and head, which the walk reads anyway to detect the only each file's size and head, which the walk reads anyway to detect the
file's type, so it costs little. The Duplicates tab compares files byte for file's type, so it costs little. Only for the groups it lists, the
byte on request and never deletes or modifies them; it tells you where to Duplicates tab also reads each member's tail, and a listed group is the
look. Hard links and bind-mounted copies of one file group as copies, and files sharing both head and tail, so the extra reads are paid where
deleting one frees nothing; verification calls them identical, which they precision matters. On 86k real files at 8 KiB, the head alone misgroups
are. 3.4% of them and head and tail 0.1%. Nothing is listed before its tail is
checked: a file whose tail cannot be read is left out, and so is a group
it leaves with one file. A hidden group is remembered by head and tail. The
Duplicates tab compares files byte for byte on request and never deletes or
modifies them; it tells you where to look. Hard links and bind-mounted
copies of one file group as copies, and deleting one frees nothing;
verification calls them identical, which they are.
- Only one instance may open an index for writing where the filesystem - Only one instance may open an index for writing where the filesystem
supports locks. A second launch raises the running window instead, and supports locks. A second launch raises the running window instead, and
refuses if it cannot rather than risk corruption; where the filesystem refuses if it cannot rather than risk corruption; where the filesystem

View file

@ -71,10 +71,13 @@ ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"]
[processing] [processing]
# Bytes read from the start of each file to hash it (with its size) for # Bytes read from the start of each file to hash it (with its size) for
# duplicate detection and to detect its type. Files of equal size with # duplicate detection and to detect its type. Files of equal size with
# identical heads are reported as duplicates even if they differ later; # identical heads hash alike: 3.4% of the files on a measured disk at 8192,
# verify on the Duplicates tab before deleting. 8192 is the default; 16384 # 1.6% at 16384. The Duplicates tab also reads this many bytes from the end
# is also reasonable. Changing it rebuilds the index. Clamped to # of each file it lists and splits apart files whose ends differ, which
# 262..1048576. # brings those to 0.1% and 0.06%. Files that differ only in between are
# still listed together, so verify on the Duplicates tab before deleting.
# 8192 is the default; 16384 is also reasonable. Changing it rebuilds the
# index. Clamped to 262..1048576.
hash_length = 8192 hash_length = 8192
# Most extracted text stored per file (bytes). Clamped to 1..16777216. # Most extracted text stored per file (bytes). Clamped to 1..16777216.
maximum_text_size = 262144 maximum_text_size = 262144
@ -178,15 +181,15 @@ debounce_ms = 150
live_results = true live_results = true
# Memory, in MiB, the search connection keeps index pages in, held while # Memory, in MiB, the search connection keeps index pages in, held while
# searching and released after a long idle. 0 (recommended) sizes it from the # searching and released after a long idle. 0 (recommended) sizes it from the
# index: about 168 bytes per indexed file, capped at 128 MiB. # index: about 150 bytes per indexed file, capped at 192 MiB.
# #
# It matters for an encrypted index, which must decrypt every page the cache # It matters for an encrypted index, which must decrypt every page the cache
# misses (at 600k files: 127 ms per keystroke with too small a cache, 34 ms # misses (at 1M files: 99 ms per keystroke with too small a cache, 59 ms with
# with enough). An unencrypted index gets a flat 16 MiB; more measured no # enough). An unencrypted index gets a flat 16 MiB to save memory, though one
# faster. # large enough to hold its file list measured 10 to 25% faster.
# #
# Set it by hand only for deeply nested folders (wider rows) or indexes over # Set it by hand only for deeply nested folders (wider rows) or indexes over
# ~800k files, where the automatic cap is too low. Clamped to 16..1024. # ~1.3M files, where the automatic cap is too low. Clamped to 16..1024.
cache_size_mib = 0 cache_size_mib = 0
# Which columns the Search tab shows; the right-click menu of any column # Which columns the Search tab shows; the right-click menu of any column
@ -219,9 +222,11 @@ rank = true
# copies, this is the key to use. # copies, this is the key to use.
exclude_patterns = [] exclude_patterns = []
# Groups dismissed with "Hide this group", for groups that only look like # Groups dismissed with "Hide this group", for groups that only look like
# duplicates, since grouping reads just each file's size and first # duplicates, since grouping reads just each file's size and its first and
# processing.hash_length bytes. Stored by content hash (lowercase hex), so a # last processing.hash_length bytes. Stored by content (the head hash, then
# hidden group stays hidden when its files move and returns if their contents # the tail digest, in lowercase hex), so a hidden group stays hidden when its
# change. Changing processing.hash_length rebuilds the index and strands these # files move and returns if their contents change. Entries saved by earlier
# entries; the tab's Clear button empties the list. # versions hold the head hash alone and no longer match any group. Changing
# processing.hash_length rebuilds the index and strands these entries; the
# tab's Clear button empties the list.
hidden_groups = [] hidden_groups = []

View file

@ -86,8 +86,9 @@ regex = "1"
# regex passes ever see. Transitive already (regex). # regex passes ever see. Transitive already (regex).
regex-syntax = "0.8" regex-syntax = "0.8"
# SIMD substring search for the full-text passes: std's Two-Way searcher has # SIMD substring search for the full-text passes: std's Two-Way searcher has
# no vector prefilter (`benches/search.rs`, `substring`). Transitive already # no vector prefilter (`benches/search.rs`, `substring`). `memchr2` also finds
# (regex, globset, chardetng). # the filename pass's case-insensitive candidates (`filename_ladder`).
# Transitive already (regex, globset, chardetng).
memchr = "2" memchr = "2"
# `Local`, for the user's calendar days in `modified:`: the OS zone rules # `Local`, for the user's calendar days in `modified:`: the OS zone rules
# (tzdata on Unix, the Win32 API on Windows) place midnight, DST gaps and # (tzdata on Unix, the Win32 API on Windows) place midnight, DST gaps and

View file

@ -107,7 +107,13 @@ pub fn header(arm: &Arm) -> i64 {
/// produced it. The middle two rows separate those. /// produced it. The middle two rows separate those.
pub fn attribution(arm: &Arm) { pub fn attribution(arm: &Arm) {
let like = format!("%{}%", BODY_TERM); let like = format!("%{}%", BODY_TERM);
let match_expr = format!("text: \"{}\"", BODY_TERM); // Built the way the cascade builds it: a quoted phrase is refused by the
// default `detail=none` index, so it is an AND of trigrams there.
let match_expr = quicksearch_core::search::prefilter::fts_literal(
BODY_TERM,
db::repo::fts_positions(&arm.open_search()),
)
.expect("BODY_TERM clears the trigram floor");
let shapes: [(&str, &str, &str); 4] = [ let shapes: [(&str, &str, &str); 4] = [
( (

View file

@ -103,9 +103,9 @@ mod text_pipeline {
} }
} }
/// The MIME sniff, once per new or changed file: ~180 ns on a text head /// The MIME sniff, once per new or changed file: ~120 ns on a text head
/// against ~2.2 µs for the `classify` next to it, so its allocations are not /// against ~2.5–3.3 µs for the `classify` next to it, so its allocations are
/// worth removing. This group keeps that ratio visible. /// not worth removing. This group keeps that ratio visible.
mod mime_sniff { mod mime_sniff {
use super::*; use super::*;
use std::path::Path; use std::path::Path;
@ -302,8 +302,8 @@ mod directory_event {
/// The same directory's own `Modify`: a `chmod`, a `touch`, an xattr, one /// The same directory's own `Modify`: a `chmod`, a `touch`, an xattr, one
/// of the directory events a `chmod -R` sends, and on Windows any change /// of the directory events a `chmod -R` sends, and on Windows any change
/// among its entries. Its files report their own changes, so it walks /// among its entries. Its files report their own changes, so it walks
/// nothing. Measured: 1.16 ms → 1.13 µs median; before, it walked the /// nothing. Measured: 1.16 ms → about 761 ns median; before, it walked
/// tree as a `Create` does, at `unchanged`'s cost. /// the tree as a `Create` does, at `unchanged`'s cost.
#[divan::bench] #[divan::bench]
fn modified(bencher: Bencher) { fn modified(bencher: Bencher) {
let (dir, db) = Scratch::db("bench-modified-event"); let (dir, db) = Scratch::db("bench-modified-event");

View file

@ -11,10 +11,11 @@
//! cargo bench -p quicksearch-core --bench page_geometry //! cargo bench -p quicksearch-core --bench page_geometry
//! ``` //! ```
//! //!
//! Point `TMPDIR` at real storage; see `arms::warn_if_tmpdir_unset`. The //! Point `TMPDIR` at real storage; see `arms::warn_if_tmpdir_unset`. Each
//! matrix wanted ~2 GB of scratch at a time, since each arm is dropped once //! arm is dropped once measured, so scratch peaks at one 1M-file arm, about
//! measured; that predates the seed's uncapped WAL (`testutil::seed_index`), //! 2.4 GB: its ~300 MiB database plus a WAL that `testutil::seed_index`
//! which needs room on top. //! checkpoints as a default-config run does, at `maximum_wal_size` (2 GiB;
//! less on a volume with under ~8 GiB free).
//! //!
//! # Settled: FTS5 record size //! # Settled: FTS5 record size
//! //!
@ -48,11 +49,8 @@
//! //!
//! The plain arm is the noise floor. Cold is SQLite-cold: a fresh //! The plain arm is the noise floor. Cold is SQLite-cold: a fresh
//! connection, so its cache starts empty. Nothing evicts the OS cache, so no //! connection, so its cache starts empty. Nothing evicts the OS cache, so no
//! tier is storage-cold. Warm runs at the cache ceiling each arm ships with; //! tier is storage-cold. Warm runs at the cache ceiling each arm ships with.
//! the sweep on `db::schema::PAGE_SIZE` predates that and ran at the 16 MiB //! Report the tiers separately; do not average them.
//! floor, below the `files` table, so its warm ranking prices per-page work,
//! not a warm keystroke at the shipped ceiling. Report the tiers separately;
//! do not average them.
mod arms; mod arms;
@ -66,9 +64,9 @@ use quicksearch_core::testutil::{Arm, SeedSpec};
/// room. /// room.
const SWEPT: [i64; 7] = [1024, 2048, 4096, 8192, 16384, 32768, 65536]; const SWEPT: [i64; 7] = [1024, 2048, 4096, 8192, 16384, 32768, 65536];
/// `QSB_PGSZ_SIZES=8192,16384` narrows the sweep. A full run takes ~45 /// `QSB_PGSZ_SIZES=8192,16384` narrows the sweep. A full run takes about 5
/// minutes, nearly all of it seeding, so re-asking one question should not /// minutes, most of it seeding, so re-asking one question should not re-ask
/// re-ask all of them. /// all of them.
fn swept() -> Vec<i64> { fn swept() -> Vec<i64> {
match std::env::var("QSB_PGSZ_SIZES") { match std::env::var("QSB_PGSZ_SIZES") {
Ok(list) => list Ok(list) => list

View file

@ -32,7 +32,7 @@ fn literal(term: &str) -> TermPattern {
} }
/// Decompressing the stored body, the first thing every full-text row does. /// Decompressing the stored body, the first thing every full-text row does.
/// Reusing one context and pre-sizing the output is ~4x faster at 1 KiB, the /// Reusing one context and pre-sizing the output is 5.1x faster at 1 KiB, the
/// size most documents are; `DocDecoder` exists for that. /// size most documents are; `DocDecoder` exists for that.
mod zstd_decode { mod zstd_decode {
use super::*; use super::*;
@ -54,7 +54,7 @@ mod zstd_decode {
/// Turning decompressed bytes into a `&str`: `from_utf8_lossy` copies and /// Turning decompressed bytes into a `&str`: `from_utf8_lossy` copies and
/// scan-validates; `String::from_utf8` vector-validates and *moves*. A /// scan-validates; `String::from_utf8` vector-validates and *moves*. A
/// 16-28x gap; `DocDecoder` borrows instead. /// 14-16x gap; `DocDecoder` borrows instead.
mod utf8 { mod utf8 {
use super::*; use super::*;
@ -116,8 +116,8 @@ mod fold {
/// Substring search: std's Two-Way against `memmem`'s SIMD prefilter. The /// Substring search: std's Two-Way against `memmem`'s SIMD prefilter. The
/// miss case matters most (a pass verifies far more rows than it accepts): /// miss case matters most (a pass verifies far more rows than it accepts):
/// 111 µs against 2.4 µs at 256 KiB. `match_indices` stays as the guard: if /// 92 µs against 4.7 µs at 256 KiB (20x). `match_indices` stays as the
/// the two converge, the SIMD path has stopped being selected. /// guard: if the two converge, the SIMD path has stopped being selected.
mod substring { mod substring {
use super::*; use super::*;
@ -134,8 +134,9 @@ mod substring {
bencher.bench(|| finder.find_iter(divan::black_box(text)).count()); bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
} }
/// A `Finder` built per call: measured indistinguishable from a hoisted /// A `Finder` built per call: +14 ns against a hoisted one at 1 KiB and
/// one, since the precompute is O(needle) and a term is a few bytes. /// indistinguishable from 16 KiB up, since the precompute is O(needle)
/// and a term is a few bytes.
#[divan::bench(args = corpus::SIZES)] #[divan::bench(args = corpus::SIZES)]
fn memmem_per_call_miss(bencher: Bencher, size: usize) { fn memmem_per_call_miss(bencher: Bencher, size: usize) {
let text = corpus::text(size, 0).as_bytes(); let text = corpus::text(size, 0).as_bytes();
@ -226,10 +227,19 @@ mod snippet_extract {
} }
} }
/// The filename pass's per-row ladder over 2000 realistic rows. The two /// The filename pass's per-row ladder over 2000 realistic rows. `current` is
/// obvious fixes each measure *worse* alone (reused fold buffer, prebuilt /// the shipped `find_first`, which jumps between spellings of the term's
/// `Finder`); only together do they win, and only by ~1.2x: short strings /// first byte with `memchr2`. Adopting that took it from 53.4 µs to 45.4 µs,
/// do not behave like document bodies. /// 1.18x (2026-10-11, mimalloc, fastest sample, median of 3 runs), with the
/// other arms within 4%. Folding first does not catch up: the two obvious
/// fixes each measure *worse* alone (reused fold buffer, prebuilt `Finder`),
/// and together they only tie it. Short strings do not behave like document
/// bodies.
///
/// The other arms are local copies, which skip `find_first`'s per-call cost
/// (about 1.5 ns, 6 µs a run). The `memchr2` copy that prompted the change
/// measured 1.39x where the shipped call gained 1.18x, so gate a change on
/// `current` before and after it, not on a copy.
mod filename_ladder { mod filename_ladder {
use super::*; use super::*;
@ -324,26 +334,22 @@ mod filename_ladder {
}); });
} }
/// memchr2 candidate scan: measured, not worth it on short haystacks. /// The per-byte scan `find_first` ran until 2026-10-11: every start
/// tested, `needle` already lowercased. The `memchr2` scan that replaced
/// it was first rejected under glibc as not worth it on short haystacks;
/// re-measured under mimalloc, its copy here ran 38.5 µs against
/// `current`'s 53.4 µs. This copy runs 47.9 µs.
#[divan::bench] #[divan::bench]
fn find_first_ci_memchr2(bencher: Bencher) { fn find_first_ci_bytewise(bencher: Bencher) {
let rows = corpus::rows(); let rows = corpus::rows();
let needle = corpus::NEEDLE.as_bytes(); let needle = corpus::NEEDLE.as_bytes();
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> { fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
let (lo, up) = ( let first = needle[0];
needle[0].to_ascii_lowercase(),
needle[0].to_ascii_uppercase(),
);
let last = hay.len().checked_sub(needle.len())?; let last = hay.len().checked_sub(needle.len())?;
let mut at = 0usize; (0..=last).find(|&i| {
while at <= last { hay[i].to_ascii_lowercase() == first
let i = at + memchr::memchr2(lo, up, &hay[at..=last])?; && hay[i..i + needle.len()].eq_ignore_ascii_case(needle)
if hay[i..i + needle.len()].eq_ignore_ascii_case(needle) { })
return Some(i);
}
at = i + 1;
}
None
} }
bencher.bench(|| { bencher.bench(|| {
let mut found = 0usize; let mut found = 0usize;

View file

@ -15,8 +15,6 @@
//! characters, unpredicated below that). So the working set scales with file //! characters, unpredicated below that). So the working set scales with file
//! count, not document volume, and the corpus dimension below shows it. //! count, not document volume, and the corpus dimension below shows it.
//! //!
//! Keyed figures predate `HMAC_MODE = Off`; see `db::schema::HMAC_MODE`.
//!
//! Printed rather than asserted: shared-box timings are not stable enough for //! Printed rather than asserted: shared-box timings are not stable enough for
//! a pass/fail gate, and a flaky perf gate gets muted rather than fixed. //! a pass/fail gate, and a flaky perf gate gets muted rather than fixed.
//! //!
@ -25,47 +23,47 @@
//! cargo bench -p quicksearch-core --bench search_perf //! cargo bench -p quicksearch-core --bench search_perf
//! ``` //! ```
//! //!
//! Point `TMPDIR` at real storage (see `arms::warn_if_tmpdir_unset`); the //! Point `TMPDIR` at real storage (see `arms::warn_if_tmpdir_unset`). One arm
//! 1M-file arms were ~580 MB each, before the seed's uncapped WAL //! is held at a time, and a 1M-file arm peaks at about 2.5 GB of scratch: its
//! (`testutil::seed_index`), which needs room on top. Budget ~20 minutes, //! 344 MiB database plus a WAL that `testutil::seed_index` checkpoints as a
//! nearly all of it seeding. //! default-config run does, at `maximum_wal_size` (2 GiB; less on a volume
//! with under ~8 GiB free). Budget about 2 minutes, most of it seeding.
//! //!
//! # What it found //! # What it found
//! //!
//! Warm search, the mean keystroke of one settled pass, //! Warm search, the mean keystroke of one settled pass, medians of 3 runs,
//! `schema::PAGE_SIZE` = 8192, rows at a realistic width (139 B; see //! `schema::PAGE_SIZE` = 8192, `HMAC_MODE = Off`, rows at a realistic width
//! [`spec`]), on the earlier fixed grid of 1–256 MiB (powers of two plus 24, //! (137 B; see [`spec`]), on [`cache_sizes`]'s grid (2026-10-11):
//! 48 and 96):
//! //!
//! | corpus | `files` table | keyed, under knee | keyed, at knee | knee | ratio | //! | corpus | `files` table | keyed, under knee | keyed, at knee | knee | ratio |
//! |---|---|---|---|---|---| //! |---|---|---|---|---|---|
//! | 200k | 26.5 MiB | 41.4 ms (≤24 MiB) | **10.7 ms** | 32 MiB | ≤1.21x | //! | 200k | 26.1 MiB | 18.1–18.4 ms (≤1.0x) | **12.1 ms** | 27.4 MiB | 1.05x |
//! | 600k | 79.5 MiB | 121.2 ms (≤64 MiB) | **33.7 ms** | 96 MiB | ≤1.21x | //! | 600k | 78.3 MiB | 57.8–61.3 ms (≤1.0x) | **35.6 ms** | 82.2 MiB | 1.05x |
//! | 1M | 132.4 MiB | 199.6 ms (≤128 MiB) | **58.3 ms** | 256 MiB | ≤1.93x | //! | 1M | 130.4 MiB | 85.3–99.0 ms (≤1.0x) | **60.0 ms** | 136.9 MiB | 1.05x |
//! //!
//! 1. The knee is at most 1.21x the `files` table: the grid brackets it in //! 1. The knee is at 1.05x the `files` table on every corpus: the table
//! (24, 32] MiB at 200k and (64, 96] at 600k, and the two agree by //! itself is under it. Hence 137 B/row × 1.05 = 144 B, plus about 4% of
//! construction, since corpus and grid both step by 3. 1M's lies in //! margin: 150 B, `schema::SEARCH_CACHE_BYTES_PER_FILE`.
//! (128, 256], where 1.21x predicts 160. Hence 139 B/row × 1.21 = 168 B, //! 2. Below the knee an encrypted index is 1.5–1.7x slower (3.4–3.9x under
//! `schema::SEARCH_CACHE_BYTES_PER_FILE`, which errs large. //! HMAC-SHA512), and the step is a cliff, not a slope: 600k keyed ran
//! 2. Below the knee an encrypted index is 3.4–3.9x slower, and the step is a //! 58–61 ms at every ceiling up to the table and 35.6 ms at 1.05x.
//! cliff, not a slope: 600k keyed ran 121–130 ms at every ceiling up to //! 3. Plain has no cliff, but it runs 10–25% faster once the cache holds the
//! 64 MiB and 33.7 ms at 96. //! `files` table: 13.0 → 10.7–12.1 ms at 200k, 43.0 → 34.3–36.6 ms at
//! 3. Plain has no knee. Its widest spread was 1.35x and it is not monotonic //! 600k, 63.6 → 57.5–59.2 ms at 1M, from its flat 16 MiB to 1.05x the
//! (32 and 48 MiB measured slower than 1 MiB at 600k): run-to-run noise, //! table and up. A miss on a plain index is a `memcpy` from the OS cache;
//! not a curve. A miss on a plain index is a `memcpy` from the OS cache; on //! on a keyed one it is an AES-CBC decrypt. `schema::SEARCH_CACHE_PLAIN_MIB`
//! a keyed one it is an AES-CBC decrypt. Hence //! stays flat by choice, for memory.
//! `schema::SEARCH_CACHE_PLAIN_MIB`, flat.
//! //!
//! Row width is half the answer. The default seed's narrow rows (`hash` NULL, //! Row width is half the answer. The default seed's narrow rows (`hash` NULL,
//! a 16-character `/seed/NNN/` parent) measure 69.5 B, half of a realistic //! a 16-character `/seed/NNN/` parent) measure 69.5 B, half of a realistic
//! 139 B; calibrating on them would under-size every cache by two and put //! 139 B; calibrating on them would under-size every cache by two and put
//! every user back under the knee. //! every user back under the knee.
//! //!
//! The 128 MiB automatic cap (`schema::SEARCH_CACHE_MAX_MIB`) binds at ~800k //! The 192 MiB automatic cap (`schema::SEARCH_CACHE_MAX_MIB`) binds at
//! files. At 1M the index wants ~160 MiB and gets 128: 205 ms per keystroke //! ~1.34M files, and the knee passes it at ~1.4M. The cap was 128 MiB, under
//! against the 58 ms available at 256. `[search] cache_size_mib` is how a //! 1M's knee: 99.0 ms per keystroke there, against 58.9 ms at the 143 MiB it
//! user trades memory for that speed. //! now derives. Past ~1.4M, `[search] cache_size_mib` is how a user trades
//! memory for that speed.
mod arms; mod arms;
@ -75,9 +73,9 @@ use quicksearch_core::db::schema::recommended_search_cache_mib;
use quicksearch_core::search::SearchOptions; use quicksearch_core::search::SearchOptions;
use quicksearch_core::testutil::{run_cascade, Arm, SeedSpec}; use quicksearch_core::testutil::{run_cascade, Arm, SeedSpec};
/// Two corpora under the automatic cap and one past it /// Three corpora under the automatic cap (`schema::SEARCH_CACHE_MAX_MIB`
/// (`schema::SEARCH_CACHE_MAX_MIB` binds at ~800k files). If the working set /// binds at ~1.34M files). If the working set is `files`, the knee moves with
/// is `files`, the knee moves with the corpus. /// the corpus.
const CORPORA: [usize; 3] = [200_000, 600_000, 1_000_000]; const CORPORA: [usize; 3] = [200_000, 600_000, 1_000_000];
/// Cache ceilings to sweep, as multiples of the arm's own `files` table. The /// Cache ceilings to sweep, as multiples of the arm's own `files` table. The
@ -122,9 +120,13 @@ const SEQUENCE: [&str; 4] = ["quar", "quart", "quartz", "quartzi"];
const KNEE_TOLERANCE: f64 = 1.10; const KNEE_TOLERANCE: f64 = 1.10;
/// How much worse the worst ceiling must be than the best before the curve /// How much worse the worst ceiling must be than the best before the curve
/// counts as a knee. A real one is a 4x step (keyed at 200k: 41.4 ms below /// counts as a knee. A real one was a 3.4–3.9x step under HMAC-SHA512 and is
/// 32 MiB, 10.7 ms at or above). [`KNEE_TOLERANCE`] alone would find a knee /// 1.5–1.7x with `HMAC_MODE = Off` (keyed at 200k: 18.1 ms at the table,
/// in a plain arm's ~1.3x run-to-run noise, so this gate sits well above it. /// 12.1 ms at 1.05x), right at this gate. A plain arm's spread, its 10–25%
/// gain once the table fits plus noise, measured 1.13–1.65x, so the two
/// overlap: across the 2026-10-11 runs one keyed arm missed its knee at 1.50x
/// and two plain arms reported one. Read a knee off several runs, not one.
/// [`KNEE_TOLERANCE`] alone would find a knee in every plain arm.
const KNEE_MIN_SPREAD: f64 = 1.5; const KNEE_MIN_SPREAD: f64 = 1.5;
fn enabled() -> bool { fn enabled() -> bool {

View file

@ -4,7 +4,9 @@
//! so one number decides both. Collisions are measured over `1..32 KiB` //! so one number decides both. Collisions are measured over `1..32 KiB`
//! against the 32 KiB baseline, which is right by definition; `--verify` //! against the 32 KiB baseline, which is right by definition; `--verify`
//! reads the baseline's groups byte for byte and reports how many of those //! reads the baseline's groups byte for byte and reports how many of those
//! were wrong too. //! were wrong too. The `+tail` columns apply the Duplicates tab's tail check
//! (`search::split_by_tail`) at each sample size, the tail as long as the
//! head.
//! //!
//! ```text //! ```text
//! cargo build -p quicksearch-core --example hashprobe --release //! cargo build -p quicksearch-core --example hashprobe --release
@ -25,7 +27,7 @@ mod common;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::io::Read; use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -68,6 +70,10 @@ struct Scan {
size: u64, size: u64,
/// `sha256(size ‖ head[..n])` for each `n` in [`SWEEP`]. /// `sha256(size ‖ head[..n])` for each `n` in [`SWEEP`].
digests: [Sha; SWEEP.len()], digests: [Sha; SWEEP.len()],
/// For each `n`, `sha256` of the bytes `search::split_by_tail` reads
/// with a tail of `n`: from `max(n, size - n)` to the end. Zero for a
/// file no larger than `n`, which the head hashed whole.
tails: [Sha; SWEEP.len()],
/// Bit `i`: the MIME guessed from `SWEEP[i]` bytes differs from the /// Bit `i`: the MIME guessed from `SWEEP[i]` bytes differs from the
/// baseline's, since a short sample also classifies on less evidence. /// baseline's, since a short sample also classifies on less evidence.
mime_differs: u8, mime_differs: u8,
@ -87,22 +93,43 @@ struct ScanErrors {
irregular: u64, irregular: u64,
} }
/// Read one file's head (`min(size, 32 KiB)`) once and derive every sample /// Read one file's head (`min(size, 32 KiB)`) and the tail past it (at most
/// size's answer from it, so the six answers are consistent with each other /// the last 32 KiB) once, and derive every sample size's answer from them, so
/// as six separate runs could not be. /// the six answers are consistent with each other as six separate runs could
/// not be.
fn scan_one(path: &Path, size: u64, config: &Config, registry: &Registry) -> Option<Scan> { fn scan_one(path: &Path, size: u64, config: &Config, registry: &Registry) -> Option<Scan> {
let mut head = vec![0u8; size.min(SWEEP[BASE] as u64) as usize]; let base = SWEEP[BASE] as u64;
let mut head = vec![0u8; size.min(base) as usize];
let mut file = File::open(path).ok()?; let mut file = File::open(path).ok()?;
// `read_exact`, like the indexer: a file that shrank between the stat and // `read_exact`, like the indexer: a file that shrank between the stat and
// this read is not a file we know the size of, so it is not scanned. // this read is not a file we know the size of, so it is not scanned.
file.read_exact(&mut head).ok()?; file.read_exact(&mut head).ok()?;
// Every smaller sample's tail lies inside this one or the head: below
// `2 × base` the two are the whole file, and above it every tail is a
// suffix of this one.
let rest_start = base.max(size.saturating_sub(base));
let mut rest = vec![0u8; size.saturating_sub(rest_start) as usize];
if !rest.is_empty() {
file.seek(SeekFrom::Start(rest_start)).ok()?;
file.read_exact(&mut rest).ok()?;
}
let mut digests = [[0u8; 32]; SWEEP.len()]; let mut digests = [[0u8; 32]; SWEEP.len()];
for (slot, n) in digests.iter_mut().zip(SWEEP) { let mut tails = [[0u8; 32]; SWEEP.len()];
for ((slot, tail), n) in digests.iter_mut().zip(&mut tails).zip(SWEEP) {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(size.to_le_bytes()); hasher.update(size.to_le_bytes());
hasher.update(&head[..head.len().min(n)]); hasher.update(&head[..head.len().min(n)]);
slot.copy_from_slice(&hasher.finalize()); slot.copy_from_slice(&hasher.finalize());
let n = n as u64;
if size > n {
let start = n.max(size - n);
let mut hasher = Sha256::new();
hasher.update(&head[head.len().min(start as usize)..]);
hasher.update(&rest[start.saturating_sub(rest_start) as usize..]);
tail.copy_from_slice(&hasher.finalize());
}
} }
let base_mime = guess_mime_from_head(path, &head); let base_mime = guess_mime_from_head(path, &head);
@ -124,6 +151,7 @@ fn scan_one(path: &Path, size: u64, config: &Config, registry: &Registry) -> Opt
Some(Scan { Some(Scan {
size, size,
digests, digests,
tails,
mime_differs, mime_differs,
text_differs, text_differs,
extractable, extractable,
@ -322,6 +350,20 @@ fn partition(scans: &[Scan], which: usize) -> Vec<Vec<u32>> {
by_digest.into_values().collect() by_digest.into_values().collect()
} }
/// [`partition`], each group then split by its members' tails as
/// `search::split_by_tail` splits it with `tail_len = SWEEP[which]`. The
/// size is already in the head digest.
fn partition_with_tail(scans: &[Scan], which: usize) -> Vec<Vec<u32>> {
let mut by_digest: HashMap<(Sha, Sha), Vec<u32>> = HashMap::new();
for (i, scan) in scans.iter().enumerate() {
by_digest
.entry((scan.digests[which], scan.tails[which]))
.or_default()
.push(i as u32);
}
by_digest.into_values().collect()
}
fn baseline_ids(scans: &[Scan]) -> Vec<u32> { fn baseline_ids(scans: &[Scan]) -> Vec<u32> {
let mut ids = vec![0u32; scans.len()]; let mut ids = vec![0u32; scans.len()];
let mut next: HashMap<Sha, u32> = HashMap::new(); let mut next: HashMap<Sha, u32> = HashMap::new();
@ -334,21 +376,41 @@ fn baseline_ids(scans: &[Scan]) -> Vec<u32> {
struct Sweep { struct Sweep {
rows: Vec<Collisions>, rows: Vec<Collisions>,
/// The same, after the tail check.
tailed: Vec<Collisions>,
} }
impl Sweep { impl Sweep {
/// Assert the invariants that hold by construction: the baseline's /// Assert the invariants that hold by construction: the baseline's
/// false-positive columns are zero, and every "wrong" column is /// false-positive columns are zero, and every "wrong" column is
/// non-increasing as the sample widens. A probe that reports a plausible /// non-increasing as the sample widens. The tail check only splits, so
/// wrong number is worse than none. /// its rows obey the same and never report more than the head alone
/// (head and tail at `2n` cover what they cover at `n`). A probe that
/// reports a plausible wrong number is worse than none.
fn check_invariants(&self) { fn check_invariants(&self) {
let base = &self.rows[BASE]; Sweep::check_rows(&self.rows);
Sweep::check_rows(&self.tailed);
for (head, tailed) in self.rows.iter().zip(&self.tailed) {
assert!(tailed.files <= head.files, "the tail check only splits");
assert!(
tailed.fp_files <= head.fp_files,
"the tail check only splits"
);
assert!(
tailed.fp_pairs <= head.fp_pairs,
"the tail check only splits"
);
}
}
fn check_rows(rows: &[Collisions]) {
let base = &rows[BASE];
assert_eq!(base.fp_groups, 0, "the baseline defines truth"); assert_eq!(base.fp_groups, 0, "the baseline defines truth");
assert_eq!(base.fp_files, 0, "the baseline defines truth"); assert_eq!(base.fp_files, 0, "the baseline defines truth");
assert_eq!(base.fp_pairs, 0, "the baseline defines truth"); assert_eq!(base.fp_pairs, 0, "the baseline defines truth");
assert_eq!(base.overstated_bytes, 0, "the baseline defines truth"); assert_eq!(base.overstated_bytes, 0, "the baseline defines truth");
for pair in self.rows.windows(2) { for pair in rows.windows(2) {
let (small, large) = (&pair[0], &pair[1]); let (small, large) = (&pair[0], &pair[1]);
assert!( assert!(
large.files <= small.files, large.files <= small.files,
@ -441,6 +503,9 @@ fn collide(roots: &[String], workers: usize, ignore: &[String], verify_budget: O
rows: (0..SWEEP.len()) rows: (0..SWEEP.len())
.map(|i| tally(&partition(&scans, i), &base, &sizes)) .map(|i| tally(&partition(&scans, i), &base, &sizes))
.collect(), .collect(),
tailed: (0..SWEEP.len())
.map(|i| tally(&partition_with_tail(&scans, i), &base, &sizes))
.collect(),
}; };
sweep.check_invariants(); sweep.check_invariants();
@ -450,12 +515,12 @@ fn collide(roots: &[String], workers: usize, ignore: &[String], verify_budget: O
sample_name(SWEEP[BASE]) sample_name(SWEEP[BASE])
); );
println!( println!(
"| sample | dup groups | dup files | reported pairs | FP groups | FP files | FP pairs | FP rate (pairs) | FP rate (files) | overstated |" "| sample | dup groups | dup files | reported pairs | FP groups | FP files | FP pairs | FP rate (pairs) | FP rate (files) | overstated | FP files +tail | FP rate (files) +tail | overstated +tail |"
); );
println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|"); println!("|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
for (i, row) in sweep.rows.iter().enumerate() { for (i, (row, tailed)) in sweep.rows.iter().zip(&sweep.tailed).enumerate() {
println!( println!(
"| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |", "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
sample_name(SWEEP[i]), sample_name(SWEEP[i]),
row.groups, row.groups,
row.files, row.files,
@ -466,6 +531,9 @@ fn collide(roots: &[String], workers: usize, ignore: &[String], verify_budget: O
pct(row.fp_pairs, row.reported_pairs), pct(row.fp_pairs, row.reported_pairs),
pct(row.fp_files, files), pct(row.fp_files, files),
mib(row.overstated_bytes), mib(row.overstated_bytes),
tailed.fp_files,
pct(tailed.fp_files, files),
mib(tailed.overstated_bytes),
); );
} }
@ -833,6 +901,41 @@ mod tests {
assert_eq!(total.overstated_bytes, 20); assert_eq!(total.overstated_bytes, 20);
} }
/// Each sample's tail digest covers the bytes `split_by_tail` reads,
/// wherever the size falls against the head and the 32 KiB read past it.
#[test]
fn tail_digests_cover_what_the_tail_check_reads() {
let dir = quicksearch_core::testutil::scratch_dir("hashprobe-tails");
let (config, registry) = (Config::default(), Registry::default_set());
let base = SWEEP[BASE];
for size in [
1,
1024,
1025,
3000,
base,
base + 1,
base + 5000,
2 * base,
2 * base + 1,
5 * base + 7,
] {
let body: Vec<u8> = (0..size).map(|i| (i * 31 % 251) as u8).collect();
let path = dir.join(format!("{size}.bin"));
std::fs::write(&path, &body).unwrap();
let scan = scan_one(&path, size as u64, &config, &registry).expect("scanned");
for (tail, n) in scan.tails.iter().zip(SWEEP) {
let want: Sha = if size > n {
Sha256::digest(&body[n.max(size - n)..]).into()
} else {
[0; 32]
};
assert_eq!(*tail, want, "size {size}, tail {n}");
}
}
std::fs::remove_dir_all(&dir).ok();
}
/// The hardlink-farm case: the pair count is quadratic, so it must be /// The hardlink-farm case: the pair count is quadratic, so it must be
/// computed, not enumerated. /// computed, not enumerated.
#[test] #[test]

View file

@ -75,7 +75,10 @@ pub struct IndexingConfig {
#[serde(default)] #[serde(default)]
pub struct ProcessingConfig { pub struct ProcessingConfig {
/// Bytes read from the head of each new or changed file: the identity /// Bytes read from the head of each new or changed file: the identity
/// hash window, the MIME sniff window, and the inline-extract cap. /// hash window, the MIME sniff window, and the inline-extract cap. Also
/// the tail the Duplicates tab reads from each listed file
/// ([`crate::search::split_by_tail`]): at the default 8 KiB, head alone
/// misgroups 3.44% of 86k real files and head and tail 0.10%.
/// Changing it invalidates stored hashes and rebuilds the index: at once /// Changing it invalidates stored hashes and rebuilds the index: at once
/// in automatic mode, otherwise when confirmed or at the next run. /// in automatic mode, otherwise when confirmed or at the next run.
/// `examples/hashprobe.rs` measures what a different value would trade. /// `examples/hashprobe.rs` measures what a different value would trade.
@ -194,13 +197,16 @@ pub struct DuplicatesConfig {
/// is not counted, and a group left with fewer than two members is not /// is not counted, and a group left with fewer than two members is not
/// listed at all. /// listed at all.
pub exclude_patterns: Vec<String>, pub exclude_patterns: Vec<String>,
/// Content hashes of groups dismissed with "Hide this group", lowercase /// Identities of groups dismissed with "Hide this group": head hash then
/// hex (see [`crate::search::DuplicateGroup::hash_hex`]). Keyed by hash /// tail digest, lowercase hex (see
/// rather than by path so a hidden group stays hidden when its files are /// [`crate::search::DuplicateGroup::identity`]). Keyed by content rather
/// renamed or moved, and comes back when their contents change. /// than by path so a hidden group stays hidden when its files are renamed
/// or moved, and comes back when their contents change. Anything else,
/// such as the head hash alone that earlier versions saved, is dropped
/// when the config loads.
/// ///
/// The hash covers `processing.hash_length` bytes, so changing that /// Both cover `processing.hash_length` bytes, so changing that setting
/// setting (which rebuilds the index anyway) strands every entry here. /// (which rebuilds the index anyway) strands every entry here.
pub hidden_groups: Vec<String>, pub hidden_groups: Vec<String>,
} }
@ -375,6 +381,12 @@ fn default_ignore_patterns() -> Vec<String> {
patterns.into_iter().map(str::to_string).collect() patterns.into_iter().map(str::to_string).collect()
} }
/// Whether `id` is shaped like [`crate::search::DuplicateGroup::identity`]:
/// two SHA-256 digests, head then tail, as 128 lowercase hex digits.
fn is_group_identity(id: &str) -> bool {
id.len() == 128 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// The home directory, else `.`. A home whose name is not valid Unicode /// The home directory, else `.`. A home whose name is not valid Unicode
/// gives no root: the walk skips such paths silently, and a lossy spelling /// gives no root: the walk skips such paths silently, and a lossy spelling
/// must never become a database key. /// must never become a database key.
@ -548,6 +560,15 @@ impl Config {
for warning in cfg.clamp_out_of_range() { for warning in cfg.clamp_out_of_range() {
crate::log_warn!("config: {}", warning); crate::log_warn!("config: {}", warning);
} }
let hidden = cfg.duplicates.hidden_groups.len();
cfg.duplicates.hidden_groups.retain(|id| is_group_identity(id));
if cfg.duplicates.hidden_groups.len() < hidden {
crate::log_warn!(
"config: dropped {} hidden duplicate group(s) that are not a head and tail \
identity (saved by an older version); hide them again if they come back",
hidden - cfg.duplicates.hidden_groups.len()
);
}
IgnoreSet::compile(&cfg.indexing.ignore_patterns) IgnoreSet::compile(&cfg.indexing.ignore_patterns)
.map_err(|e| format!("Invalid ignore pattern in {}: {}", path.display(), e))?; .map_err(|e| format!("Invalid ignore pattern in {}: {}", path.display(), e))?;
Ok(cfg) Ok(cfg)

View file

@ -36,6 +36,33 @@ fn fresh_install_defaults_to_home_as_only_root() {
assert_eq!(partial.paths.indexing_paths, cfg.paths.indexing_paths); assert_eq!(partial.paths.indexing_paths, cfg.paths.indexing_paths);
} }
/// Only head and tail identities survive a load: the head hash alone that
/// earlier versions saved, and anything malformed, would match no group yet
/// still count as hidden.
#[test]
fn hidden_groups_that_are_not_identities_are_dropped_on_load() {
let dir = tmp_dir();
let path = dir.join("config.toml");
let identity = "ab".repeat(32) + &"cd".repeat(32);
let stale = [
"ab".repeat(32), // head hash alone, as saved before
"AB".repeat(64), // uppercase
"zz".repeat(64), // not hex
"ab".repeat(64) + "a", // one digit too many
String::new(),
];
let mut entries: Vec<String> = stale.to_vec();
entries.insert(2, identity.clone());
let list = entries
.iter()
.map(|e| format!("\"{e}\""))
.collect::<Vec<_>>()
.join(", ");
fs::write(&path, format!("[duplicates]\nhidden_groups = [{list}]\n")).unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.duplicates.hidden_groups, [identity]);
}
#[test] #[test]
fn missing_file_created_with_defaults() { fn missing_file_created_with_defaults() {
let dir = tmp_dir(); let dir = tmp_dir();
@ -1232,7 +1259,7 @@ fn ui_bookkeeping_fields_are_soft_knobs() {
c.duplicates.exclude_patterns = vec!["*.iso".to_string()] c.duplicates.exclude_patterns = vec!["*.iso".to_string()]
}), }),
("duplicates.hidden_groups", |c| { ("duplicates.hidden_groups", |c| {
c.duplicates.hidden_groups = vec!["ab".repeat(32)] c.duplicates.hidden_groups = vec!["ab".repeat(64)]
}), }),
]; ];
for (label, mutate) in cases { for (label, mutate) in cases {
@ -1255,7 +1282,8 @@ fn newer_fields_round_trip() {
cfg.ui.color_scheme = "light".to_string(); cfg.ui.color_scheme = "light".to_string();
cfg.search.fuzzy_max_edits = 4; cfg.search.fuzzy_max_edits = 4;
cfg.duplicates.exclude_patterns = vec!["*.iso".to_string()]; cfg.duplicates.exclude_patterns = vec!["*.iso".to_string()];
cfg.duplicates.hidden_groups = vec!["ab".repeat(32)]; // A group's identity: its head hash, then its tail digest.
cfg.duplicates.hidden_groups = vec!["ab".repeat(32) + &"cd".repeat(32)];
cfg.save().unwrap(); cfg.save().unwrap();
let loaded = Config::load_from(&path).unwrap(); let loaded = Config::load_from(&path).unwrap();
assert_eq!( assert_eq!(

View file

@ -78,22 +78,28 @@ pub(crate) fn open_or_recreate_keyed(
// Neither the page size nor the page reserve changes in place, so a // Neither the page size nor the page reserve changes in place, so a
// file that opened only under an older profile is rebuilt whatever // file that opened only under an older profile is rebuilt whatever
// its schema says. // its schema says.
ProfileMatch::Previous(found) => { ProfileMatch::Previous(found) => Some(format!(
format!("was built with {} and this build uses {}", found, want) "was built with {} and this build uses {}",
} found, want
)),
ProfileMatch::Current if db_matches_current(&conn, processing)? => { ProfileMatch::Current if db_matches_current(&conn, processing)? => {
narrow_index_files(&path); narrow_index_files(&path);
conn.execute_batch(PRAGMAS_FAST) conn.execute_batch(PRAGMAS_FAST)
.map_err(|e| format!("Failed to apply pragmas: {}", e))?; .map_err(|e| format!("Failed to apply pragmas: {}", e))?;
return Ok(conn); return Ok(conn);
} }
ProfileMatch::Current => "does not match current schema".to_string(), // A path just created, or an empty file: nothing is rebuilt, so
// creating the schema is not worth a warning.
ProfileMatch::Current if holds_no_schema(&conn)? => None,
ProfileMatch::Current => Some("does not match current schema".to_string()),
}; };
crate::log_warn!( if let Some(why) = why {
"database at {} {}; rebuilding. Existing rows will be re-scanned on next indexing run.", crate::log_warn!(
db_path, "database at {} {}; rebuilding. Existing rows will be re-scanned on next indexing run.",
why db_path,
); why
);
}
let conn = wipe_and_reopen(conn, &path, key, want)?; let conn = wipe_and_reopen(conn, &path, key, want)?;
apply_current_schema(&conn, processing, key, want)?; apply_current_schema(&conn, processing, key, want)?;
Ok(conn) Ok(conn)
@ -269,9 +275,11 @@ pub fn index_needs_rebuild(db_path: &str) -> bool {
if matched != ProfileMatch::Current { if matched != ProfileMatch::Current {
return true; return true;
} }
// Only `Ok` answers count: an `Err` means we could not tell. // Only `Ok` answers count: an `Err` means we could not tell. An empty
// file is only given the schema, which is no reset.
matches!(schema_version_current(&conn), Ok(false)) matches!(schema_version_current(&conn), Ok(false))
&& matches!(is_ours_or_empty(&conn), Ok(true)) && matches!(is_ours_or_empty(&conn), Ok(true))
&& matches!(holds_no_schema(&conn), Ok(false))
} }
/// Serialises SQLCipher's process-wide `cipher_default_*` settings, which /// Serialises SQLCipher's process-wide `cipher_default_*` settings, which
@ -475,6 +483,17 @@ fn is_ours_or_empty(conn: &Connection) -> Result<bool, String> {
Ok(!any) Ok(!any)
} }
/// Whether the file holds no schema at all: a path just created, or an empty
/// file. `sqlite_%` names are excluded, as in [`is_ours_or_empty`].
fn holds_no_schema(conn: &Connection) -> Result<bool, String> {
conn.query_row(
"SELECT NOT EXISTS (SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%')",
[],
|r| r.get(0),
)
.map_err(|e| format!("read sqlite_master: {}", e))
}
/// The refusal message, naming a few of the tables in the way. /// The refusal message, naming a few of the tables in the way.
fn foreign_database_message(conn: &Connection) -> Result<String, String> { fn foreign_database_message(conn: &Connection) -> Result<String, String> {
let mut stmt = conn let mut stmt = conn

View file

@ -111,6 +111,45 @@ fn reopen_is_idempotent() {
assert_eq!(info(&conn, "version"), CURRENT_SCHEMA_VERSION.to_string()); assert_eq!(info(&conn, "version"), CURRENT_SCHEMA_VERSION.to_string());
} }
/// Rebuild warnings in the process log that name `p`. Every scratch path is
/// unique, so lines other tests log at the same time cannot match.
fn rebuild_warnings(p: &str) -> usize {
crate::log::snapshot()
.iter()
.filter(|line| {
line.level == crate::log::Level::Warn
&& line.text.contains(p)
&& line.text.contains("rebuilding")
})
.count()
}
/// A new index has nothing to rebuild, so creating one must not warn of a
/// schema mismatch, as it did in every CI run and on the GUI's first launch.
/// A real mismatch still warns.
#[test]
fn only_a_real_schema_mismatch_warns_of_a_rebuild() {
let (_dir, fresh) = db();
drop(open_or_recreate(&fresh, &Default::default()).unwrap());
assert_eq!(rebuild_warnings(&fresh), 0, "a path that did not exist");
let (_dir, keyed) = db();
drop(open_or_recreate_keyed(&keyed, &Default::default(), Some(&test_key(0xa3))).unwrap());
assert_eq!(rebuild_warnings(&keyed), 0, "a new encrypted index");
let (_dir, empty) = db();
std::fs::write(&empty, b"").unwrap();
let conn = open_or_recreate(&empty, &Default::default()).unwrap();
assert_eq!(info(&conn, "version"), CURRENT_SCHEMA_VERSION.to_string());
assert_eq!(rebuild_warnings(&empty), 0, "an empty file");
let (_dir, old) = db();
write_v1_index(&old, "a.txt");
let conn = open_or_recreate(&old, &Default::default()).unwrap();
assert_eq!(file_count(&conn), 0, "the mismatch is still rebuilt");
assert_eq!(rebuild_warnings(&old), 1, "a real schema mismatch");
}
#[test] #[test]
fn older_versioned_db_is_wiped_and_recreated() { fn older_versioned_db_is_wiped_and_recreated() {
let (_dir, p) = db(); let (_dir, p) = db();
@ -391,8 +430,13 @@ type Fixture = fn(&str);
/// announces a wipe that is not happening. /// announces a wipe that is not happening.
#[test] #[test]
fn index_needs_rebuild_only_when_the_schema_really_differs() { fn index_needs_rebuild_only_when_the_schema_really_differs() {
let cases: [(Fixture, bool, &str); 7] = [ let cases: [(Fixture, bool, &str); 8] = [
(|_| {}, false, "no file yet is a fresh install, not a reset"), (|_| {}, false, "no file yet is a fresh install, not a reset"),
(
|p| std::fs::write(p, b"").unwrap(),
false,
"an empty file holds nothing to reset",
),
( (
|p| drop(open_or_recreate(p, &Default::default()).unwrap()), |p| drop(open_or_recreate(p, &Default::default()).unwrap()),
false, false,

View file

@ -55,22 +55,25 @@ pub const PRAGMAS_INCREMENTAL: &str = "
/// Cache ceiling for an unencrypted index, whatever its size. /// Cache ceiling for an unencrypted index, whatever its size.
/// ///
/// `benches/search_perf.rs` swept 1–256 MiB on 200k–1M files and found no /// `benches/search_perf.rs` swept 1–256 MiB on 200k–1M files. A plain index,
/// knee on a plain index, where a page-cache miss is a `memcpy` from the OS /// where a page-cache miss is a `memcpy` from the OS cache, has no cliff, but
/// cache. Only SQLCipher makes a miss expensive: it caches pages decrypted, /// it runs 10–25% faster once the cache holds the `files` table (600k: 43.0 ms
/// so a miss pays the AES-CBC again. /// per keystroke at 16 MiB, 34.4 ms at 1.2x the table). It is kept at a flat
/// 16 MiB by choice, for memory. Only SQLCipher makes a miss expensive: it
/// caches pages decrypted, so a miss pays the AES-CBC again.
pub const SEARCH_CACHE_PLAIN_MIB: i64 = 16; pub const SEARCH_CACHE_PLAIN_MIB: i64 = 16;
/// Floor and ceiling for the derived value. The floor is the smallest the /// Floor and ceiling for the derived value. The floor is the smallest the
/// sweep found sufficient. /// sweep found sufficient.
/// ///
/// The cap limits resident memory and binds at about 800k files, beyond which /// The cap limits resident memory. It binds at about 1.34M files, and the knee
/// an encrypted index sits under its knee: at 1M files (a 132 MiB `files` /// (1.05x the `files` table, about 144 B per file) passes it at about 1.4M,
/// table) a keystroke costs 205 ms at 128 MiB against 58 ms at 256. Trading a /// beyond which an encrypted index sits under its knee and runs 1.5–1.7x
/// third of a gigabyte for a 3.5x faster session is the user's call, through /// slower. Trading memory for that speed is the user's call, through
/// [`SEARCH_CACHE_OVERRIDE_MAX_MIB`]. /// [`SEARCH_CACHE_OVERRIDE_MAX_MIB`]. The cap was 128 MiB, which left 1M files
/// under their knee; the figures are at [`SEARCH_CACHE_BYTES_PER_FILE`].
pub const SEARCH_CACHE_MIN_MIB: i64 = 16; pub const SEARCH_CACHE_MIN_MIB: i64 = 16;
pub const SEARCH_CACHE_MAX_MIB: i64 = 128; pub const SEARCH_CACHE_MAX_MIB: i64 = 192;
/// Ceiling on an explicit `[search] cache_size_mib`, well above the automatic /// Ceiling on an explicit `[search] cache_size_mib`, well above the automatic
/// cap: the override exists for a large encrypted index whose knee is past /// cap: the override exists for a large encrypted index whose knee is past
@ -82,17 +85,25 @@ pub const SEARCH_CACHE_OVERRIDE_MAX_MIB: i64 = 1024;
const _: () = assert!(SEARCH_CACHE_OVERRIDE_MAX_MIB > SEARCH_CACHE_MAX_MIB); const _: () = assert!(SEARCH_CACHE_OVERRIDE_MAX_MIB > SEARCH_CACHE_MAX_MIB);
const _: () = assert!(SEARCH_CACHE_MIN_MIB <= SEARCH_CACHE_MAX_MIB); const _: () = assert!(SEARCH_CACHE_MIN_MIB <= SEARCH_CACHE_MAX_MIB);
/// Cache bytes to allow per indexed file: 139 bytes per `files` row times the /// Cache bytes to allow per indexed file: 137 bytes per `files` row times
/// 1.21x headroom the sweep found sufficient. /// 1.05, where the knee sits, is 144 B; about 4% of margin makes 150.
/// ///
/// Every keystroke rescans the `files` table (filename queries are a `LIKE` /// Every keystroke rescans the `files` table (filename queries are a `LIKE`
/// scan with no FTS), so the working set is that table and the warm curve's /// scan with no FTS), so the working set is that table and the warm curve's
/// knee sits at the first ceiling that holds it. The sweep, the knee per /// knee sits at the first ceiling that holds it. Keyed warm keystroke,
/// corpus and the row-width caveat are in `benches/search_perf.rs`. The /// `HMAC_MODE = Off`, medians of 3 (`benches/search_perf.rs`, 2026-10-11):
/// figures predate `HMAC_MODE = Off` (see [`HMAC_MODE`]), which makes this ///
/// conservative. 139 B/row assumes ordinary path lengths; a much deeper tree /// | files | `files` table | knee | 168 B, 128 MiB cap | 150 B, 192 MiB cap |
/// wants more, which `[search] cache_size_mib` covers. /// |---|---|---|---|---|
pub const SEARCH_CACHE_BYTES_PER_FILE: i64 = 168; /// | 200k | 26.1 MiB | 27.4 MiB | 14.0 ms at 32 MiB | 12.0 ms at 28 MiB |
/// | 1M | 130.4 MiB | 136.9 MiB | 99.0 ms at 128 MiB | 58.9 ms at 143 MiB |
///
/// Both 200k ceilings are past the knee, so their difference is noise. At 1M
/// the ceilings from the knee to 256 MiB measured 56–64 ms. The sweep, the
/// knee per corpus and the row-width caveat are in `benches/search_perf.rs`.
/// 137 B/row assumes ordinary path lengths; rows over ~4% wider, as in a much
/// deeper tree, sit under the knee, which `[search] cache_size_mib` covers.
pub const SEARCH_CACHE_BYTES_PER_FILE: i64 = 150;
/// The cache ceiling this index wants, in MiB. /// The cache ceiling this index wants, in MiB.
pub fn recommended_search_cache_mib(files: i64, keyed: bool) -> i64 { pub fn recommended_search_cache_mib(files: i64, keyed: bool) -> i64 {
@ -215,27 +226,26 @@ CREATE TABLE config_validation (
/// ///
/// 8192 rather than the SQLite and SQLCipher default of 4096: /// 8192 rather than the SQLite and SQLCipher default of 4096:
/// `benches/page_geometry.rs` swept 1024→65536 on a keyed 200k-file index, /// `benches/page_geometry.rs` swept 1024→65536 on a keyed 200k-file index,
/// with every search connection at the 16 MiB cache floor (`seed_index` /// `HMAC_MODE = Off`, each search connection at its shipped ceiling, medians
/// recorded no root counts then), and this balances two opposing costs: /// of 3 runs (2026-10-11):
/// ///
/// | page | size | `files` scan | scattered rows | cold name | /// | page | size | seed rows/s | `files` scan | scattered rows | cold name | warm total |
/// |---|---|---|---|---| /// |---|---|---|---|---|---|---|
/// | 1024 | 179.9 MiB | 221 ms | 51 ms | 79.4 ms | /// | 1024 | 72.3 MiB | 54,054 | 21.1 ms | 1.12 ms | 39.3 ms | 154.3 ms |
/// | 4096 | 154.8 MiB | 100 ms | 43 ms | 44.4 ms | /// | 2048 | 61.5 MiB | 60,606 | 13.7 ms | 0.93 ms | 24.9 ms | 133.9 ms |
/// | **8192** | **151.4 MiB** | **55 ms** | **45 ms** | **35.7 ms** | /// | 4096 | 60.9 MiB | 62,500 | 11.6 ms | 1.08 ms | 20.6 ms | 121.4 ms |
/// | 16384 | 151.4 MiB | 37 ms | 58 ms | 34.2 ms | /// | **8192** | **59.2 MiB** | **66,667** | **12.7 ms** | **1.44 ms** | **18.5 ms** | **117.4 ms** |
/// | 65536 | 158.4 MiB | 23 ms | 61 ms | 38.1 ms | /// | 16384 | 58.6 MiB | 66,667 | 10.4 ms | 2.22 ms | 19.8 ms | 110.2 ms |
/// | 32768 | 58.7 MiB | 58,824 | 10.0 ms | 3.74 ms | 18.4 ms | 118.6 ms |
/// | 65536 | 59.1 MiB | 54,054 | 9.1 ms | 4.51 ms | 18.7 ms | 114.5 ms |
/// ///
/// A scattered row fetch decrypts a whole page to read one ~130-byte row, so /// A scattered row fetch decrypts a whole page to read one ~130-byte row, so
/// it wants a small page and degrades past 8192 (43 ms → 73 ms by 32768). The /// it wants a small page and degrades from 2048 up (0.93 ms → 4.51 ms by
/// sequential `files` scan behind every filename query wants a large one: the /// 65536). The sequential `files` scan behind every filename query wants a
/// bytes decrypted stay ~9 MiB either way while per-page codec and pager work /// large one: the bytes decrypted stay ~9 MiB either way while per-page codec
/// falls. 8192 keeps scattered reads within 3% of their optimum, halves the /// and pager work falls. Below 4096 the file grows and the scan and both
/// scan, and ties for the smallest file. /// query totals slow; from 4096 up the curve is flat. 8192 seeds fastest
/// /// (tied with 16384) and ties for the best cold name.
/// The keyed columns predate `HMAC_MODE = Off` (see [`HMAC_MODE`]). Both costs
/// are per-page work, so dropping the authenticator scales them together, and
/// the plain arm picked 8192 too.
/// ///
/// **Changing this is not a free edit**; see [`PROFILES_PREVIOUS`]. /// **Changing this is not a free edit**; see [`PROFILES_PREVIOUS`].
pub const PAGE_SIZE: i64 = 8192; pub const PAGE_SIZE: i64 = 8192;
@ -314,42 +324,51 @@ impl HmacMode {
/// ///
/// # What it buys /// # What it buys
/// ///
/// `benches/cipher_hmac.rs`, 200k files, all four arms in one process, every /// `benches/cipher_hmac.rs`, 200k files, all four arms in one process,
/// search connection at the 16 MiB cache floor (`seed_index` recorded no root /// medians of 3 runs (2026-10-11). Each search connection ran at its arm's
/// counts then), below the `files` table. Warm total is six cascade shapes /// shipped ceiling: 16 MiB plain, and 32 MiB keyed, which like today's 28 MiB
/// summed, so it prices per-page work, not a keystroke at the shipped /// holds the 19.3 MiB `files` table. Cold and warm totals are six cascade
/// ceiling; its "body (common)" shape filled the display limit from file /// shapes summed, cold on a fresh connection:
/// names and never reached the full-text pass:
/// ///
/// | arm | seed | warm total | duplicates | size | /// | arm | seed | cold total | warm total | duplicates | size |
/// |---|---|---|---|---| /// |---|---|---|---|---|---|
/// | plain (no password) | 30.9 s | 125.6 ms | 206 ms | 151.5 MiB | /// | plain (no password) | 2.9 s | 142.1 ms | 125.5 ms | 52 ms | 59.1 MiB |
/// | **keyed, HMAC off** | **37.7 s** | **146.2 ms** | **221 ms** | **151.5 MiB** | /// | **keyed, HMAC off** | **2.9 s** | **165.1 ms** | **110.6 ms** | **58 ms** | **59.2 MiB** |
/// | keyed, HMAC_SHA256 | 40.0 s | 209.0 ms | 250 ms | 150.6 MiB | /// | keyed, HMAC_SHA256 | 3.2 s | 237.3 ms | 110.6 ms | 74 ms | 59.4 MiB |
/// | keyed, HMAC_SHA512 | 41.3 s | 259.5 ms | 262 ms | 151.4 MiB | /// | keyed, HMAC_SHA512 | 3.4 s | 290.9 ms | 115.9 ms | 84 ms | 59.6 MiB |
/// ///
/// Against SQLCipher's default that is 1.78x on warm search, and it takes /// Cold is the case that matters: QuickSearch is usually launched fresh from
/// encrypted-over-plain from 2.07x to 1.16x, 84% of the cost of having a /// its shortcut, searched and closed, so nearly every search starts on a cold
/// password. A filename query went 32.6 ms → 13.8 ms and a rare body term /// connection. That is where the HMAC is paid, on every page decrypted, as it
/// 39.8 → 18.6. /// is in the duplicate scan and the seed. Cold, encrypted over plain is 1.16x
/// with the HMAC off against 2.05x under SHA-512, so `Off` removes 85% of the
/// cold cost of having a password: a cold filename query went 38.7 ms →
/// 18.3 ms (plain 14.1) and a cold rare body term 49.5 → 24.3 (plain 20.2).
/// Only in a long-running session, once the cache holds what a keystroke
/// reads, are the keyed arms within noise of each other (the plain arm's flat
/// 16 MiB sits under its table, so it re-reads pages from the OS cache).
/// ///
/// SHA-256 recovers only 1.24x of the 2.07x despite halving the digest, /// At 1M files, with the keyed arms capped at 128 MiB (above their 97 MiB
/// `files` table), the shape holds: cold total 825 ms off against 1428 ms
/// under SHA-512 (plain 627), duplicates 292 against 432 ms, seed 27.3 against
/// 41.8 s, and warm 535–569 ms on every keyed arm.
///
/// SHA-256 recovers only 1.23x of the 2.05x despite halving the digest,
/// because the digest is not the cost: `sqlcipher_openssl_hmac` calls /// because the digest is not the cost: `sqlcipher_openssl_hmac` calls
/// `EVP_MAC_fetch(NULL, "HMAC", NULL)`, `EVP_MAC_CTX_new` and an /// `EVP_MAC_fetch(NULL, "HMAC", NULL)`, `EVP_MAC_CTX_new` and an
/// `EVP_MAC_init` that fetches the digest by name, two OpenSSL 3 provider /// `EVP_MAC_init` that fetches the digest by name, two OpenSSL 3 provider
/// lookups per page whichever digest is chosen. Only `Off` removes them. /// lookups per page whichever digest is chosen. Only `Off` removes them.
/// ///
/// Size is unmoved (151.5 against 151.4 MiB): [`fts_pgsz_for`] re-derives the /// Size barely moves (59.2 against 59.6 MiB): [`fts_pgsz_for`] re-derives the
/// record size from the new reserve, so the 64 bytes handed back per page go /// record size from the new reserve, so the 64 bytes handed back per page go
/// into leaves. /// into leaves.
/// ///
/// # Figures taken under HMAC-SHA512 /// # Figures taken under HMAC-SHA512
/// ///
/// [`PAGE_SIZE`]'s sweep, [`SEARCH_CACHE_BYTES_PER_FILE`], and the keyed /// Only the FTS5 record-size table in `benches/page_geometry.rs`, whose keyed
/// figures in `benches/search_perf.rs`, `benches/page_geometry.rs` and /// columns predate `Off`. [`PAGE_SIZE`]'s sweep, the search cache sizing
/// `search::IDLE_RELEASE` were measured under HMAC-SHA512 and not re-swept. /// ([`SEARCH_CACHE_BYTES_PER_FILE`], [`SEARCH_CACHE_MAX_MIB`]) and
/// A keyed page miss is now cheaper, so those keyed costs are overstated and /// `search::IDLE_RELEASE` were re-measured under `Off`.
/// the cache sizing errs large.
/// ///
/// # Changing it is not a free edit /// # Changing it is not a free edit
/// ///
@ -553,8 +572,8 @@ mod tests {
} }
} }
/// The sweep found no knee on a plain index at any corpus size, so this /// A plain index has no cliff to stay above, and its ceiling is held flat
/// must not grow with one. /// by choice, for memory, so this must not grow with the corpus.
#[test] #[test]
fn a_plain_index_gets_the_same_ceiling_at_every_size() { fn a_plain_index_gets_the_same_ceiling_at_every_size() {
for files in [0, 1_000, 200_000, 1_000_000, 50_000_000] { for files in [0, 1_000, 200_000, 1_000_000, 50_000_000] {
@ -597,12 +616,13 @@ mod tests {
assert!(large <= SEARCH_CACHE_MAX_MIB); assert!(large <= SEARCH_CACHE_MAX_MIB);
} }
/// The two corpora `benches/search_perf.rs` measured under the automatic /// The corpora `benches/search_perf.rs` measured, against the knee it
/// cap, against the knee it found for each. Below the knee a keyed search /// found for each: 1.05x the `files` table, in whole MiB rounded up. Below
/// ran 3.4–3.9x slower (HMAC-SHA512), so the recommendation must reach it. /// the knee a keyed search ran 1.5–1.7x slower, so the recommendation must
/// reach it.
#[test] #[test]
fn the_recommendation_clears_every_measured_knee() { fn the_recommendation_clears_every_measured_knee() {
for (files, knee_mib) in [(200_000, 32), (600_000, 96)] { for (files, knee_mib) in [(200_000, 28), (600_000, 83), (1_000_000, 137)] {
let got = recommended_search_cache_mib(files, true); let got = recommended_search_cache_mib(files, true);
assert!( assert!(
got >= knee_mib, got >= knee_mib,
@ -627,7 +647,7 @@ mod tests {
); );
} }
/// An explicit setting must be able to exceed the automatic cap: past ~800k /// An explicit setting must be able to exceed the automatic cap: past ~1.4M
/// files the derived value is capped below the measured knee, and the /// files the derived value is capped below the measured knee, and the
/// override is the only way to reach it. /// override is the only way to reach it.
#[test] #[test]

View file

@ -103,9 +103,12 @@ pub(crate) fn mtime_secs(meta: &std::fs::Metadata) -> u64 {
pub type FileHash = [u8; 32]; pub type FileHash = [u8; 32];
/// Identify a file as `sha256(size || first hash_length bytes)`, leaving the /// Identify a file as `sha256(size || first hash_length bytes)`, leaving the
/// head bytes in `head` for MIME sniffing. The cost is a collision class: /// head bytes in `head` for MIME sniffing. Head only, for indexing speed: the
/// same-size files with identical heads read as duplicates /// walk reads these bytes anyway. The cost is a collision class: same-size
/// (`examples/hashprobe.rs` measures it); `crate::verify` is the way out. /// files with identical heads hash alike, 3.44% of 86k real files at 8 KiB
/// (`examples/hashprobe.rs` measures it). The Duplicates tab's tail check
/// ([`crate::search::split_by_tail`]) brings that to 0.10%, and
/// `crate::verify` is the byte-exact way out.
/// ///
/// Returned with the opened handle's metadata, which is where the hashed size /// Returned with the opened handle's metadata, which is where the hashed size
/// comes from: a record built from it describes the file that was read, not /// comes from: a record built from it describes the file that was read, not

View file

@ -17,6 +17,7 @@ mod progress;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub(crate) use pipeline::WalGuard;
pub use progress::{ pub use progress::{
overall_progress, MaintenanceStep, OverallProgress, ReconcileProgress, RootPhase, RootProgress, overall_progress, MaintenanceStep, OverallProgress, ReconcileProgress, RootPhase, RootProgress,
}; };

View file

@ -781,8 +781,9 @@ fn rotated<T>(v: &mut [T], round: usize) -> impl Iterator<Item = &mut T> {
/// holds a read mark, and a run keeps a reader per root from start to /// holds a read mark, and a run keeps a reader per root from start to
/// finish; an explicit checkpoint retries that lock under `busy_timeout` and /// finish; an explicit checkpoint retries that lock under `busy_timeout` and
/// wins. So the writer forces one each time the log grows by `cap`, and /// wins. So the writer forces one each time the log grows by `cap`, and
/// stops the run when the volume nears [`DISK_FLOOR`]. /// stops the run when the volume nears [`DISK_FLOOR`]. `testutil::seed_index`
struct WalGuard { /// holds one too, so a seeded index's log is bounded as a run's is.
pub(crate) struct WalGuard {
path: String, path: String,
/// 0 when the log is unbounded. /// 0 when the log is unbounded.
cap: u64, cap: u64,
@ -791,8 +792,8 @@ struct WalGuard {
} }
impl WalGuard { impl WalGuard {
fn new(config: &Config, db_path: &str) -> WalGuard { pub(crate) fn new(processing: &crate::config::ProcessingConfig, db_path: &str) -> WalGuard {
let configured = match config.processing.maximum_wal_size { let configured = match processing.maximum_wal_size {
0 => 0, 0 => 0,
n => n.max(crate::config::MINIMUM_WAL_SIZE), n => n.max(crate::config::MINIMUM_WAL_SIZE),
}; };
@ -804,25 +805,32 @@ impl WalGuard {
} }
} }
/// Checkpoint if a round that made progress left the log past `next_at`, /// Whether the log has grown past `next_at`.
/// then stop the run if the disk is nearly full. pub(crate) fn due(&self) -> bool {
self.cap != 0 && wal_len(&self.path) >= self.next_at
}
/// Checkpoint now, then re-arm from what is on disk: a lost race costs
/// one attempt per further `cap` of growth, not one per round.
pub(crate) fn checkpoint(&mut self, conn: &Connection) {
if let Err(e) = crate::db::repo::checkpoint_truncate(conn) {
crate::log_warn!("{}", e);
}
self.next_at = wal_len(&self.path) + self.cap;
}
/// [`Self::checkpoint`] if a round that made progress left the log past
/// `next_at`, then stop the run if the disk is nearly full.
fn after_round(&mut self, cx: &RunCx<'_>, progressed: bool) -> Result<(), String> { fn after_round(&mut self, cx: &RunCx<'_>, progressed: bool) -> Result<(), String> {
// Called after `publish_status`: the checkpoint may block for // Called after `publish_status`: the checkpoint may block for
// `busy_timeout`, and it must not sit in front of `stop_indexing`, // `busy_timeout`, and it must not sit in front of `stop_indexing`,
// hence the stop-flag check. // hence the stop-flag check.
if self.cap == 0 if !progressed || cx.stop_flag.load(Ordering::Relaxed) || !self.due() {
|| !progressed
|| cx.stop_flag.load(Ordering::Relaxed)
|| wal_len(&self.path) < self.next_at
{
return Ok(()); return Ok(());
} }
{ {
let _maintaining = cx.maintaining(MaintenanceStep::Checkpoint); let _maintaining = cx.maintaining(MaintenanceStep::Checkpoint);
let conn = crate::lock_ok(&cx.conn_mutex); self.checkpoint(&crate::lock_ok(&cx.conn_mutex));
if let Err(e) = crate::db::repo::checkpoint_truncate(&conn) {
crate::log_warn!("{}", e);
}
} }
// Only here, not every round: the log is at its largest, and the // Only here, not every round: the log is at its largest, and the
// checkpoint above has just returned whatever it could, so what is // checkpoint above has just returned whatever it could, so what is
@ -840,9 +848,6 @@ impl WalGuard {
)); ));
} }
} }
// Re-armed from what is on disk: a lost race costs one attempt per
// further `cap` of growth, not one per round.
self.next_at = wal_len(&self.path) + self.cap;
Ok(()) Ok(())
} }
} }
@ -980,7 +985,7 @@ impl IndexingService {
let mut cleanup_done = false; let mut cleanup_done = false;
let mut round = 0usize; let mut round = 0usize;
let mut last_census = Instant::now(); let mut last_census = Instant::now();
let mut wal = WalGuard::new(config, db_path); let mut wal = WalGuard::new(&config.processing, db_path);
// Each round serves every walking root one slice, then one extracting // Each round serves every walking root one slice, then one extracting
// root one slice. Walk workers stall once their channel fills, so // root one slice. Walk workers stall once their channel fills, so

View file

@ -1134,3 +1134,50 @@ fn the_wal_cap_is_bounded_by_the_volume() {
"disabled checkpoints on a roomy disk are effectively never" "disabled checkpoints on a roomy disk are effectively never"
); );
} }
/// `testutil::seed_index` bounds its log through [`WalGuard`]'s two halves
/// rather than through a run's `after_round`, so they are checked on their
/// own: not due under the cap, due past it, and re-armed a cap of growth
/// later once the checkpoint has emptied the log.
#[test]
fn the_wal_guard_checkpoints_once_the_log_passes_its_cap() {
let (_dir, db) = crate::testutil::Scratch::db("walguard");
let db = db.to_str().unwrap();
let processing = crate::config::ProcessingConfig {
// Raised to the floor, which no amount of free space lowers.
maximum_wal_size: 1,
..Default::default()
};
let conn = db::open_or_recreate(db, &processing).unwrap();
conn.execute_batch("PRAGMA wal_autocheckpoint = 0; CREATE TABLE ballast(b BLOB);")
.unwrap();
let wal_bytes = || std::fs::metadata(format!("{}-wal", db)).map_or(0, |m| m.len());
// One commit of about a MiB each.
let grow = |mib: usize| {
for _ in 0..mib {
conn.execute("INSERT INTO ballast VALUES (zeroblob(1048576))", [])
.unwrap();
}
};
let mut wal = WalGuard::new(&processing, db);
assert!(!wal.due(), "a fresh log is not due");
grow(8);
assert!(!wal.due(), "half the cap is not due");
grow(12);
assert!(
wal_bytes() > crate::config::MINIMUM_WAL_SIZE,
"the fixture must pass the cap"
);
assert!(wal.due(), "a log past the cap is due");
wal.checkpoint(&conn);
assert_eq!(
wal_bytes(),
0,
"with no reader, the checkpoint empties the log"
);
assert!(!wal.due(), "re-armed from the emptied log");
grow(8);
assert!(!wal.due(), "the next cap is counted from the checkpoint");
}

View file

@ -759,7 +759,9 @@ fn limited_child() {
return; return;
} }
let bytes = ask.parse::<usize>().expect("MiB") << 20; let bytes = ask.parse::<usize>().expect("MiB") << 20;
let held = vec![1u8; bytes]; // `black_box`, or a release build drops the unread allocation and the
// over-limit child exits cleanly.
let held = std::hint::black_box(vec![1u8; bytes]);
assert_eq!(held.len(), bytes); assert_eq!(held.len(), bytes);
} }

View file

@ -236,20 +236,29 @@ impl TermPattern {
} }
/// ASCII-case-insensitive [`str::find`] without folding the haystack; /// ASCII-case-insensitive [`str::find`] without folding the haystack;
/// `needle` must already be lowercased. The returned index is always a /// `needle` must already be lowercased. `memchr2` jumps to each spelling
/// `char` boundary: a `&str`'s first byte is never a continuation byte, /// of the needle's first byte, and only those candidates are compared:
/// and ASCII-only case differences keep the end a boundary too. /// 1.18x faster than testing every byte (`benches/search.rs`,
/// `filename_ladder`). The returned index is always a `char` boundary: a
/// `&str`'s first byte is never a continuation byte, and ASCII-only case
/// differences keep the end a boundary too.
fn find_ascii_ci(hay: &str, needle: &str) -> Option<usize> { 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 (h, n) = (hay.as_bytes(), needle.as_bytes());
let first = n[0]; let Some(&first) = n.first() else {
h.len().checked_sub(n.len()).and_then(|last| { return Some(0);
(0..=last).find(|&i| { };
h[i].to_ascii_lowercase() == first && h[i..i + n.len()].eq_ignore_ascii_case(n) // Equal to `first` when it has no ASCII case partner.
}) let upper = first.to_ascii_uppercase();
}) let last = h.len().checked_sub(n.len())?;
let mut at = 0;
while at <= last {
let i = at + memchr::memchr2(first, upper, &h[at..=last])?;
if h[i..i + n.len()].eq_ignore_ascii_case(n) {
return Some(i);
}
at = i + 1;
}
None
} }
/// Leftmost match as a byte range; folded offsets are valid in the original. /// Leftmost match as a byte range; folded offsets are valid in the original.
@ -528,6 +537,162 @@ mod tests {
assert_eq!(p.find_first("my report.pdf", false), None); assert_eq!(p.find_first("my report.pdf", false), None);
} }
/// What a literal's `find_first(text, true)` must return: the leftmost
/// match in the ASCII-folded haystack. Folding keeps byte length, so the
/// offsets carry over.
fn folded_reference(folded_hay: &str, folded_needle: &str) -> Option<Range<usize>> {
folded_hay
.find(folded_needle)
.map(|at| at..at + folded_needle.len())
}
fn literal(term: &str) -> TermPattern {
TermPattern::build(&[part(term, false)]).unwrap()
}
/// Every string of up to `max` characters over `alphabet`.
fn strings_over(alphabet: &[char], max: usize) -> Vec<String> {
let mut all = vec![String::new()];
let mut level = vec![String::new()];
for _ in 0..max {
level = level
.iter()
.flat_map(|s| alphabet.iter().map(move |c| format!("{s}{c}")))
.collect();
all.extend(level.iter().cloned());
}
all
}
/// The candidate scan against the reference over every short string: both
/// cases of letters, separators, `é`/`É` (one lead byte, different
/// continuations), and KELVIN SIGN and long s, which fold to `k` and `s`
/// under Unicode rules only and so must not match here.
#[test]
fn ascii_fold_find_agrees_with_a_folded_reference() {
const ALPHABET: [char; 12] = [
'a', 'A', 'b', 'B', 'k', 's', '-', '/', 'é', 'É', '\u{212A}', 'ſ',
];
let patterns: Vec<(TermPattern, String)> = strings_over(&ALPHABET, 2)
.into_iter()
.skip(1)
.map(|n| (literal(&n), n.to_ascii_lowercase()))
.collect();
for hay in strings_over(&ALPHABET, 4) {
let folded = hay.to_ascii_lowercase();
for (p, needle) in &patterns {
assert_eq!(
p.find_first(&hay, true),
folded_reference(&folded, needle),
"{needle:?} in {hay:?}"
);
}
}
}
#[test]
fn ascii_fold_find_edges() {
let find = |term: &str, hay: &str| literal(term).find_first(hay, true);
// An empty field, and a term longer than the field.
assert_eq!(find("a", ""), None);
assert_eq!(find("quartzite", "QUARTZ"), None);
assert_eq!(find("quartz", "QuArTz"), Some(0..6));
// The last start a scan may take.
assert_eq!(find("txt", "notes.TXT"), Some(6..9));
assert_eq!(find("ab", "aaaa"), None);
// A failed candidate resumes one byte on, inside the repeat.
assert_eq!(find("aab", "aaaab"), Some(2..5));
assert_eq!(find("AAB", "aAaAb"), Some(2..5));
// The leftmost of several spellings.
assert_eq!(find("Report", "x-REPORT-report"), Some(2..8));
// First bytes with no case partner.
assert_eq!(find("-01", "x-A-01"), Some(3..6));
assert_eq!(find(".txt", "a.TXT"), Some(1..5));
assert_eq!(find("/q", r"C:\Users/Q"), Some(8..10));
// ASCII folding only: `É` is not `é`, and KELVIN SIGN is not `k`.
assert_eq!(find("été", "ÉTÉ été"), Some(6..11));
assert_eq!(find("café", "CAFÉ"), None);
assert_eq!(find("café", "CAFé"), Some(0..5));
assert_eq!(find("kilo", "\u{212A}ILO"), None);
assert_eq!(find("é", "aÉé"), Some(3..5));
assert_eq!(find("b", "éB"), Some(2..3));
// Unreachable through `find_first` (an empty term is `Empty`).
assert_eq!(TermPattern::find_ascii_ci("abc", ""), Some(0));
}
/// The ladder in `search/cascade/passes.rs` `pass_filename` (ranks 1–4 on
/// the name, 9–10 on the path), with the case-insensitive find swappable.
fn ladder(
p: &TermPattern,
name: &str,
path: &str,
find_ci: &dyn Fn(&str) -> Option<Range<usize>>,
) -> Option<(u8, Range<usize>)> {
let with_paths = p.literal_char_count() >= crate::search::prefilter::TRIGRAM_FLOOR;
if p.whole_match(name, false) {
Some((1, 0..name.len()))
} else if p.whole_match(name, true) {
Some((2, 0..name.len()))
} else if let Some(r) = p.find_first(name, false) {
Some((3, r))
} else if let Some(r) = find_ci(name) {
Some((4, r))
} else if !with_paths {
None
} else if let Some(r) = p.find_first(path, false) {
Some((9, r))
} else {
find_ci(path).map(|r| (10, r))
}
}
#[test]
fn filename_tiers_agree_with_a_folded_reference() {
let rows = [
("report.txt", "/home/u/report.txt"),
("REPORT.TXT", "/home/u/REPORT.TXT"),
("Report", "/home/u/Report"),
("my-report-2024.pdf", "/data/my-report-2024.pdf"),
("MY_REPORT.pdf", "/data/MY_REPORT.pdf"),
("notes.txt", "/srv/Report/notes.txt"),
("notes.txt", "/srv/REPORT/notes.txt"),
("notes.txt", r"C:\Users\u\Report\notes.txt"),
("résumé-report.odt", "/home/u/résumé-report.odt"),
("RÉSUMÉ.odt", "/home/u/RÉSUMÉ.odt"),
("rrreport", "/x/rrreport"),
("rep", "/rep"),
("", "/"),
];
let terms = [
"report",
"Report",
"REPORT",
"rep",
"rrep",
"résumé",
"Résumé",
"RÉSUMÉ",
"report/notes",
r"Report\notes",
".txt",
"t",
];
let mut ranks = std::collections::BTreeSet::new();
for term in terms {
let p = literal(term);
let needle = term.to_ascii_lowercase();
for (name, path) in rows {
let shipped = ladder(&p, name, path, &|f| p.find_first(f, true));
let reference = ladder(&p, name, path, &|f| {
folded_reference(&f.to_ascii_lowercase(), &needle)
});
assert_eq!(shipped, reference, "{term:?} against {path:?}");
ranks.extend(shipped.map(|(rank, _)| rank));
}
}
assert_eq!(ranks.into_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 9, 10]);
}
#[test] #[test]
fn regex_defaults_case_insensitive_with_optout() { fn regex_defaults_case_insensitive_with_optout() {
let r = RegexQuery::new("foo\\d+").unwrap(); let r = RegexQuery::new("foo\\d+").unwrap();

View file

@ -19,29 +19,58 @@
//! The index exists only once a run has finished (see //! The index exists only once a run has finished (see
//! [`crate::db::repo::build_hash_index`]); before that the same query sorts //! [`crate::db::repo::build_hash_index`]); before that the same query sorts
//! the table, slower but with the same answer. //! the table, slower but with the same answer.
//!
//! The hash covers only each file's size and head, so these are
//! [`HeadGroup`]s, not for display: [`split_by_tail`] reads their tails and
//! returns the [`DuplicateGroup`]s a caller shows.
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::BinaryHeap; use std::collections::{BinaryHeap, HashMap};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::atomic::{self, AtomicBool};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use sha2::{Digest, Sha256};
use crate::db; use crate::db;
/// One member of a group: `(file_id, name, path, size, mtime)`.
pub type Member = (i64, String, String, u64, i64);
/// Files sharing a head hash, as the index groups them.
#[derive(Debug, Clone, PartialEq)]
pub struct HeadGroup {
pub hash: Vec<u8>,
/// Path-ordered. Price a group from these: the counts it was ranked by
/// came from an earlier statement, with no transaction holding the two
/// together.
pub members: Vec<Member>,
}
/// Files sharing a head hash and a tail digest: what [`split_by_tail`] makes
/// of a [`HeadGroup`], and the only kind of group to show.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct DuplicateGroup { pub struct DuplicateGroup {
/// The head hash every member was indexed with.
pub hash: Vec<u8>, pub hash: Vec<u8>,
/// `(file_id, name, path, size, mtime)` per member, path-ordered. Price a /// `sha256` of the bytes each member holds past its head. A file the head
/// group from these: the counts it was ranked by came from an earlier /// hashed whole holds none, so its digest is of nothing, and such files
/// statement, with no transaction holding the two together. /// group by head alone.
pub members: Vec<(i64, String, String, u64, i64)>, pub tail: [u8; 32],
/// Path-ordered, as in [`HeadGroup::members`].
pub members: Vec<Member>,
} }
impl DuplicateGroup { impl DuplicateGroup {
/// The group's identity, for a UI that wants to remember one: lowercase /// The group's identity, for a UI that wants to remember one: lowercase
/// hex of the hash. Stable across renames and moves, because the hash /// hex of the head hash, then of the tail digest. Stable across renames
/// covers the content and nothing about where the files live. /// and moves, because both cover content and nothing about where the
pub fn hash_hex(&self) -> String { /// files live, and distinct for each group one head hash splits into.
crate::security::hex_encode(&self.hash) pub fn identity(&self) -> String {
let mut id = crate::security::hex_encode(&self.hash);
id.push_str(&crate::security::hex_encode(&self.tail));
id
} }
} }
@ -68,14 +97,15 @@ impl PartialOrd for Ranked {
} }
} }
/// The `limit` hash groups with the most reclaimable bytes, largest first. /// The `limit` head-hash groups with the most reclaimable bytes, largest
/// Groups of one, NULL-hash rows and zero-size files are excluded. /// first; see [`split_by_tail`] before showing them. Groups of one, NULL-hash
/// rows and zero-size files are excluded.
/// ///
/// Grouping is by hash alone, so hard links and bind-mounted copies of one /// Grouping is by content alone, so hard links and bind-mounted copies of one
/// file are listed as copies, and deleting one frees nothing. That is /// file are listed as copies, and deleting one frees nothing. That is
/// documented rather than detected: they really are byte-identical, which /// documented rather than detected: they really are byte-identical, which
/// is all verification claims. /// is all verification claims.
pub fn find_duplicate_groups(db_path: &str, limit: u32) -> Result<Vec<DuplicateGroup>, String> { pub fn find_duplicate_groups(db_path: &str, limit: u32) -> Result<Vec<HeadGroup>, String> {
if limit == 0 { if limit == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -158,7 +188,7 @@ fn rank_candidates(
/// Turn the winners into full groups: the hash they were ranked by, then their /// Turn the winners into full groups: the hash they were ranked by, then their
/// members. Nothing here is proportional to the size of the index. /// members. Nothing here is proportional to the size of the index.
fn hydrate(conn: &Connection, winners: Vec<Ranked>) -> Result<Vec<DuplicateGroup>, String> { fn hydrate(conn: &Connection, winners: Vec<Ranked>) -> Result<Vec<HeadGroup>, String> {
let mut hash_stmt = conn let mut hash_stmt = conn
.prepare("SELECT hash FROM files WHERE id = ?1") .prepare("SELECT hash FROM files WHERE id = ?1")
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -196,11 +226,132 @@ fn hydrate(conn: &Connection, winners: Vec<Ranked>) -> Result<Vec<DuplicateGroup
if members.is_empty() { if members.is_empty() {
continue; continue;
} }
groups.push(DuplicateGroup { hash, members }); groups.push(HeadGroup { hash, members });
} }
Ok(groups) Ok(groups)
} }
/// Regroup each group's members by their tails, keeping only members whose
/// tail was checked and groups of two or more of them, and rank what is left
/// as [`find_duplicate_groups`] ranks. Meant for the groups a caller is about
/// to show: it reads a file per member.
///
/// # Why tails, and why here
///
/// Indexing hashes each file's size and head
/// ([`crate::file_handling::get_file_hash`]), bytes the walk reads anyway to
/// tell the file's type, so identity costs no extra read. Same-size files
/// that start alike then group as copies: build outputs, disk images,
/// documents from one template. On 86k real files that misreports 3.44% of
/// them at the default 8 KiB, and checking their last 8 KiB as well brings
/// it to 0.10%, both against a 32 KiB head (`examples/hashprobe.rs`). The
/// project once hashed tails during indexing and dropped it for speed: a
/// seek and a read more on every file indexed. Here the read is paid only
/// for the groups on screen, where precision matters.
///
/// # Rules
///
/// - A member no larger than `tail_len` was hashed whole, and is not read:
/// it holds nothing past its head, so its tail digest is of nothing, and
/// such members group by head alone.
/// - Any other is read from `max(tail_len, size - tail_len)` to its end, so
/// the tail never overlaps the head. `tail_len` is meant to be the
/// `hash_length` the index was built with; another value reads more or
/// checks less, and splits nothing that is identical.
/// - A member whose tail goes unchecked is dropped: one whose size is no
/// longer the one indexed (its head hash describes a file that has since
/// changed), one that cannot be opened or read (gone, unreadable, swapped
/// for a FIFO, or a path `never_open` names), and, once `cancel` is
/// raised, every member not yet read. Nothing comes back on its head's
/// word alone: a copy that cannot be checked goes unlisted.
/// - The rest are grouped by tail digest, the head hash being shared, and a
/// group left with fewer than two members is dropped.
///
/// Verify ([`crate::verify`]) remains the byte-exact check: a difference
/// between the head and the tail is invisible to both hashes.
///
/// Members are read one at a time into one reused buffer of at most
/// `tail_len` bytes. A read can block on slow storage, so call this on a
/// worker thread, never a UI thread.
pub fn split_by_tail(
groups: Vec<HeadGroup>,
tail_len: usize,
never_open: impl Fn(&Path) -> bool,
cancel: &AtomicBool,
) -> Vec<DuplicateGroup> {
let tail_len = tail_len as u64;
let mut buf = Vec::new();
let mut ranked: Vec<(Ranked, DuplicateGroup)> = Vec::with_capacity(groups.len());
for group in groups {
let mut by_tail: HashMap<[u8; 32], Vec<Member>> = HashMap::new();
for member in group.members {
if cancel.load(atomic::Ordering::Relaxed) {
break;
}
let path = Path::new(&member.2);
if let Ok(Some(tail)) = tail_digest(path, member.3, tail_len, &never_open, &mut buf) {
by_tail.entry(tail).or_default().push(member);
}
}
for (tail, members) in by_tail {
if members.len() < 2 {
continue;
}
let sub = DuplicateGroup {
hash: group.hash.clone(),
tail,
members,
};
ranked.push((rank(&sub.members), sub));
}
}
ranked.sort_unstable_by(|a, b| b.0.cmp(&a.0));
ranked.into_iter().map(|(_, group)| group).collect()
}
/// `sha256` of the bytes a member holds past its head, or `Ok(None)` when its
/// size has moved since indexing. A file the head hashed whole holds none and
/// is not opened. Reads into `buf`, which is resized to the tail.
fn tail_digest(
path: &Path,
size: u64,
tail_len: u64,
never_open: &dyn Fn(&Path) -> bool,
buf: &mut Vec<u8>,
) -> std::io::Result<Option<[u8; 32]>> {
if size <= tail_len {
return Ok(Some(Sha256::digest(b"").into()));
}
if never_open(path) {
return Err(std::io::ErrorKind::PermissionDenied.into());
}
// Non-blocking, so a FIFO renamed over a member is refused rather than
// waited on; the size comes from the handle, so it is the file read.
let (mut file, meta) = crate::platform::open_regular_file(path)?;
if meta.len() != size {
return Ok(None);
}
let start = tail_len.max(size - tail_len);
file.seek(SeekFrom::Start(start))?;
buf.clear();
buf.resize((size - start) as usize, 0);
file.read_exact(buf)?;
Ok(Some(Sha256::digest(&buf[..]).into()))
}
/// [`rank_candidates`]'s order for a group in hand: priced by its lowest
/// rowid's size, as the scan priced it.
fn rank(members: &[Member]) -> Ranked {
let (rowid, size) = members
.iter()
.min_by_key(|m| m.0)
.map_or((0, 0), |m| (m.0, m.3.min(i64::MAX as u64) as i64));
Ranked {
redundant: size.saturating_mul(members.len() as i64 - 1),
rowid,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -262,7 +413,7 @@ mod tests {
} }
/// Each group as `(first member's name, member count)`, in listed order. /// Each group as `(first member's name, member count)`, in listed order.
fn shape(groups: &[DuplicateGroup]) -> Vec<(&str, usize)> { fn shape(groups: &[HeadGroup]) -> Vec<(&str, usize)> {
groups groups
.iter() .iter()
.map(|g| (g.members[0].1.as_str(), g.members.len())) .map(|g| (g.members[0].1.as_str(), g.members.len()))
@ -323,34 +474,6 @@ mod tests {
); );
} }
/// The identity a UI remembers a dismissed group by must be the hash
/// itself, spelled one way, or a hidden group comes back under a
/// different spelling.
#[test]
fn a_group_spells_its_hash_the_one_way() {
let (_dir, p) = seed_db();
let groups = find_duplicate_groups(&p, 10).unwrap();
for group in &groups {
let hex = group.hash_hex();
assert_eq!(hex.len(), group.hash.len() * 2);
assert!(hex
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
}
let again = find_duplicate_groups(&p, 10).unwrap();
let spelled = |gs: &[DuplicateGroup]| gs.iter().map(|g| g.hash_hex()).collect::<Vec<_>>();
assert_eq!(
spelled(&groups),
spelled(&again),
"an unchanged index names its groups the same way twice"
);
assert_ne!(
groups[0].hash_hex(),
groups[1].hash_hex(),
"two groups must not answer to one name"
);
}
#[test] #[test]
fn zero_limit_answers_without_opening_the_index() { fn zero_limit_answers_without_opening_the_index() {
assert!(find_duplicate_groups("/nonexistent/index.sqlite", 0) assert!(find_duplicate_groups("/nonexistent/index.sqlite", 0)
@ -474,4 +597,337 @@ mod tests {
"the candidate scan must not touch table rows; plan was {plan:?}" "the candidate scan must not touch table rows; plan was {plan:?}"
); );
} }
// --- split_by_tail ------------------------------------------------------
/// The head the hand-built groups below were "indexed" with.
const TAIL: usize = 8;
/// A group as `find_duplicate_groups` hands one over: one member per body,
/// written under `dir`, indexed at its length, rowids from `first`.
fn on_disk(dir: &Path, first: i64, bodies: &[Vec<u8>]) -> HeadGroup {
let members = bodies
.iter()
.zip(first..)
.map(|(body, id)| {
let name = format!("f{id}.bin");
let path = dir.join(&name);
std::fs::write(&path, body).unwrap();
let path = path.to_str().unwrap().to_string();
(id, name, path, body.len() as u64, 1_700_000_000)
})
.collect();
HeadGroup {
hash: vec![first as u8; 32],
members,
}
}
/// `len` bytes of one head, ending in `last`.
fn ending(len: usize, last: u8) -> Vec<u8> {
let mut body = vec![b'h'; len];
body[len - 1] = last;
body
}
/// The tail digest a file holding `body` is grouped by.
fn tail_of(body: &[u8]) -> [u8; 32] {
let start = TAIL.max(body.len().saturating_sub(TAIL)).min(body.len());
Sha256::digest(&body[start..]).into()
}
/// `group` as `split_by_tail` lists it when every member checks out.
fn checked(group: &HeadGroup, tail: [u8; 32]) -> DuplicateGroup {
DuplicateGroup {
hash: group.hash.clone(),
tail,
members: group.members.clone(),
}
}
fn split(groups: &[HeadGroup]) -> Vec<DuplicateGroup> {
split_by_tail(groups.to_vec(), TAIL, |_| false, &AtomicBool::new(false))
}
/// Each group's member rowids, in listed order.
fn ids(groups: &[DuplicateGroup]) -> Vec<Vec<i64>> {
groups
.iter()
.map(|g| g.members.iter().map(|m| m.0).collect())
.collect()
}
fn path_of(group: &HeadGroup, i: usize) -> &Path {
Path::new(&group.members[i].2)
}
#[test]
fn a_different_tail_splits_the_group() {
let dir = Scratch::dir("tails-differ");
let bodies = [ending(24, b'a'), ending(24, b'a'), ending(24, b'b')];
let group = on_disk(&dir, 1, &bodies);
let refined = split(std::slice::from_ref(&group));
assert_eq!(ids(&refined), [[1, 2]], "the odd one out leaves, alone");
assert_eq!(refined[0].hash, group.hash, "a part keeps the head hash");
assert_eq!(refined[0].tail, tail_of(&bodies[0]));
}
#[test]
fn a_matching_tail_keeps_the_group_together() {
let dir = Scratch::dir("tails-match");
let body = ending(24, b'a');
let group = on_disk(&dir, 1, &[body.clone(), body.clone()]);
assert_eq!(
split(std::slice::from_ref(&group)),
[checked(&group, tail_of(&body))]
);
}
/// Two real sets of copies that start alike: a group each, named apart,
/// so hiding one cannot hide the other.
#[test]
fn two_copy_sets_sharing_a_head_are_two_groups() {
let dir = Scratch::dir("tails-two-sets");
let (a, b) = (ending(24, b'a'), ending(24, b'b'));
let group = on_disk(&dir, 1, &[a.clone(), b.clone(), a.clone(), b.clone()]);
let refined = split(std::slice::from_ref(&group));
assert_eq!(
ids(&refined),
[[1, 3], [2, 4]],
"tied on waste, so the lower rowid lists first"
);
assert_eq!(
(refined[0].tail, refined[1].tail),
(tail_of(&a), tail_of(&b))
);
assert!(refined.iter().all(|g| g.hash == group.hash));
assert_ne!(
refined[0].identity(),
refined[1].identity(),
"two groups must not answer to one name"
);
}
/// The identity a UI remembers a dismissed group by is spelled one way,
/// or a hidden group comes back under a different spelling.
#[test]
fn a_group_s_identity_is_its_head_then_its_tail() {
let dir = Scratch::dir("tails-identity");
let groups = [
on_disk(&dir, 1, &vec![ending(24, b'a'); 2]),
on_disk(&dir, 10, &vec![ending(4, b'b'); 2]),
];
let refined = split(&groups);
assert_eq!(ids(&refined), [[1, 2], [10, 11]]);
for group in &refined {
let id = group.identity();
assert_eq!(id.len(), (group.hash.len() + 32) * 2);
assert!(id
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
}
assert!(
refined[0].identity().starts_with(&"01".repeat(32)),
"the head hash first: {}",
refined[0].identity()
);
assert_eq!(
refined[1].identity(),
format!(
"{}{}",
"0a".repeat(32),
// sha256 of nothing: a file the head hashed whole.
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
);
let named = |gs: &[DuplicateGroup]| gs.iter().map(|g| g.identity()).collect::<Vec<_>>();
assert_eq!(
named(&refined),
named(&split(&groups)),
"unchanged files name their groups the same way twice"
);
}
/// The head hashed these whole, so there is nothing left to read, and they
/// group by head alone. Their files have since grown apart or gone: a read
/// would see that and drop every member.
#[test]
fn files_the_head_hashed_whole_group_by_head_unread() {
let dir = Scratch::dir("tails-small");
let mut at_tail = on_disk(&dir, 1, &[ending(20, b'a'), ending(20, b'b')]);
let mut under = on_disk(&dir, 10, &[ending(20, b'c'), ending(20, b'd')]);
for m in &mut at_tail.members {
m.3 = TAIL as u64;
}
for m in &mut under.members {
m.3 = 3;
}
std::fs::remove_file(path_of(&under, 1)).unwrap();
let groups = [at_tail, under];
let nothing = tail_of(&[]);
let whole: Vec<_> = groups.iter().map(|g| checked(g, nothing)).collect();
assert_eq!(split(&groups), whole);
assert_eq!(
split_by_tail(groups.to_vec(), TAIL, |_| true, &AtomicBool::new(false)),
whole,
"nothing is opened, so never_open has nothing to refuse"
);
}
#[test]
fn a_member_whose_size_changed_is_dropped() {
let dir = Scratch::dir("tails-resized");
let three = on_disk(&dir, 1, &vec![ending(24, b'a'); 3]);
let pair = on_disk(&dir, 10, &[ending(24, b'b'), ending(24, b'b')]);
// Grown since indexing; the head hash still matches what it was.
std::fs::write(path_of(&three, 2), ending(30, b'a')).unwrap();
std::fs::write(path_of(&pair, 1), ending(23, b'b')).unwrap();
assert_eq!(
ids(&split(&[three, pair])),
[[1, 2]],
"the changed members leave, and a pair down to one is not listed"
);
}
/// A copy whose tail cannot be read is not listed, and a group it leaves
/// with one copy is not listed at all.
#[test]
fn an_unreadable_member_is_dropped() {
let dir = Scratch::dir("tails-unreadable");
let three = on_disk(&dir, 1, &vec![ending(24, b'a'); 3]);
let pair = on_disk(&dir, 10, &vec![ending(24, b'b'); 2]);
let groups = [three.clone(), pair.clone()];
let listed = |never_open: &dyn Fn(&Path) -> bool| {
ids(&split_by_tail(
groups.to_vec(),
TAIL,
never_open,
&AtomicBool::new(false),
))
};
let everything = [vec![1, 2, 3], vec![10, 11]];
assert_eq!(listed(&|_| false), everything);
std::fs::remove_file(path_of(&three, 1)).unwrap();
std::fs::remove_file(path_of(&pair, 0)).unwrap();
assert_eq!(listed(&|_| false), [[1, 3]], "deleted since indexing");
std::fs::write(path_of(&three, 1), ending(24, b'a')).unwrap();
std::fs::write(path_of(&pair, 0), ending(24, b'b')).unwrap();
let refused = [path_of(&three, 2), path_of(&pair, 1)];
assert_eq!(
listed(&|p: &Path| refused.contains(&p)),
[[1, 2]],
"a path never_open names"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let locked = [path_of(&three, 0), path_of(&pair, 0)];
let chmod = |mode| {
for path in locked {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
};
chmod(0o000);
// Root reads them anyway, and then there is nothing to prove.
let readable = std::fs::File::open(locked[0]).is_ok();
let refined = listed(&|_| false);
chmod(0o644);
if !readable {
assert_eq!(refined, [[2, 3]], "permission denied");
}
}
assert_eq!(listed(&|_| false), everything, "readable again");
}
#[test]
fn splitting_re_ranks() {
let dir = Scratch::dir("tails-rerank");
// 300 reclaimable bytes as one group, but two pairs of 100 each.
let four = on_disk(
&dir,
1,
&[
ending(100, b'a'),
ending(100, b'a'),
ending(100, b'b'),
ending(100, b'b'),
],
);
let pair = on_disk(&dir, 10, &[ending(150, b'c'), ending(150, b'c')]);
assert_eq!(
ids(&split(&[four, pair])),
[vec![10, 11], vec![1, 2], vec![3, 4]],
"150 bytes before 100, and the lower rowid first on the tie"
);
}
/// At `2 × TAIL` the head and the tail are the whole file between them;
/// one byte longer and the byte between them is read by neither.
#[test]
fn the_tail_starts_where_the_head_ends() {
let dir = Scratch::dir("tails-boundary");
let differing_at = |len: usize, at: usize| {
let mut other = vec![b'h'; len];
other[at] = b'x';
[vec![b'h'; len], other]
};
let last_of_head = [on_disk(&dir, 1, &differing_at(2 * TAIL, TAIL - 1))];
assert_eq!(
ids(&split(&last_of_head)),
[[1, 2]],
"the head's own bytes are not read again"
);
let first_of_tail = [on_disk(&dir, 10, &differing_at(2 * TAIL, TAIL))];
assert!(split(&first_of_tail).is_empty(), "the tail starts at TAIL");
let middle = [on_disk(&dir, 20, &differing_at(2 * TAIL + 1, TAIL))];
assert_eq!(ids(&split(&middle)), [[20, 21]], "left for Verify");
}
#[cfg(unix)]
#[test]
fn a_fifo_swapped_in_is_dropped_without_blocking() {
use std::os::unix::ffi::OsStrExt;
let dir = Scratch::dir("tails-fifo");
let group = on_disk(&dir, 1, &vec![ending(24, b'a'); 3]);
let fifo = path_of(&group, 2);
std::fs::remove_file(fifo).unwrap();
let c_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0);
let (tx, rx) = std::sync::mpsc::channel();
let groups = vec![group.clone()];
std::thread::spawn(move || tx.send(split(&groups)).unwrap());
let refined = rx
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the tail check blocked opening a FIFO");
assert_eq!(ids(&refined), [[1, 2]], "refused, so not listed");
}
/// Members not yet read when `cancel` rises are dropped, never listed on
/// their head's word; what was read before it still counts.
#[test]
fn a_raised_cancel_lists_nothing_unchecked() {
let dir = Scratch::dir("tails-cancel");
let groups = [
on_disk(&dir, 1, &vec![ending(24, b'a'); 3]),
on_disk(&dir, 10, &vec![ending(24, b'b'); 2]),
];
let raised = AtomicBool::new(true);
assert!(split_by_tail(groups.to_vec(), TAIL, |_| false, &raised).is_empty());
// Raised as the second member is opened, which is still read.
let cancel = AtomicBool::new(false);
let second = path_of(&groups[0], 1);
let raise_at_second = |p: &Path| {
if p == second {
cancel.store(true, atomic::Ordering::Relaxed);
}
false
};
let refined = split_by_tail(groups.to_vec(), TAIL, raise_at_second, &cancel);
assert_eq!(ids(&refined), [[1, 2]]);
}
} }

View file

@ -23,16 +23,16 @@ use crate::query::split::split_for_cascade;
use crate::snippet::Snippet; use crate::snippet::Snippet;
pub use cascade::Outcome; pub use cascade::Outcome;
pub use duplicates::{find_duplicate_groups, DuplicateGroup}; pub use duplicates::{find_duplicate_groups, split_by_tail, DuplicateGroup, HeadGroup};
/// Idle window before the connection is released. /// Idle window before the connection is released.
/// ///
/// Long, because a release drops the search cache /// Long, so a pause in a typing session never pays to refill the search cache
/// ([`db::schema::pragmas_search`]) and the next keystroke pays to refill it: /// ([`db::schema::pragmas_search`]) that a release drops. The refill is
/// on a 200k-file index the first query after a release costs 129 ms /// modest: the first keystroke on a fresh connection costs about 15 ms
/// unencrypted and 192 ms encrypted, against ~10 ms warm, because a keyed /// unencrypted and 23 ms encrypted on a 200k-file index, against 12–14 ms
/// refill decrypts every page. (The keyed figure predates `HMAC_MODE = Off`; /// warm, and 71 against 36 ms encrypted at 600k, where a refill decrypts
/// see [`db::schema::HMAC_MODE`].) /// every page it reads (`benches/search_perf.rs`, 2026-10-11).
/// ///
/// Removals do not wait on it: the service registers with [`db::hold_index`], /// Removals do not wait on it: the service registers with [`db::hold_index`],
/// so [`db::remove_index_files`] has it send [`WorkerMsg::ReleaseConnection`] /// so [`db::remove_index_files`] has it send [`WorkerMsg::ReleaseConnection`]

View file

@ -77,8 +77,8 @@ pub fn derive_key(password: &str, salt: &[u8; SALT_LEN]) -> IndexKey {
IndexKey(out) IndexKey(out)
} }
/// The crate's only hex encoder, also how a duplicate group spells its hash /// The crate's only hex encoder, also how a duplicate group spells its
/// ([`crate::search::DuplicateGroup::hash_hex`]). /// identity ([`crate::search::DuplicateGroup::identity`]).
pub(crate) fn hex_encode(bytes: &[u8]) -> String { pub(crate) fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2); let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes { for b in bytes {

View file

@ -411,11 +411,17 @@ impl Default for SeedSpec {
/// Seed an index with synthetic rows. Shared by the measurement harnesses so /// Seed an index with synthetic rows. Shared by the measurement harnesses so
/// they all describe the same corpus. /// they all describe the same corpus.
/// ///
/// Written under a run's write regime: FTS5's write settings before the first /// Written under a default-config run's write regime: FTS5's write settings
/// row, autocheckpoint off, the run's finalize merge after the last commit. /// before the first row, autocheckpoint off, the run's WAL cap between
/// It records the root counts a completed run would, under `/seed`, so a /// commits (`indexing::WalGuard`: the default `maximum_wal_size`, lowered as
/// search connection opens at the cache ceiling it ships with for this /// a run lowers it on a volume short of space), the run's tail checkpoint
/// many files. /// after the last commit, then its finalize merge. It records the root counts a completed run would,
/// under `/seed`, so a search connection opens at the cache ceiling it ships
/// with for this many files.
///
/// Scratch therefore peaks near the cap plus the database, not at every
/// commit's pages piled into one log; `commit_every = 0` is one transaction,
/// which no checkpoint can split.
pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) { pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
use crate::db::repo::{insert_file, store_pending_text, NewFile, RootCounts}; use crate::db::repo::{insert_file, store_pending_text, NewFile, RootCounts};
use crate::file_handling::{fts_begin_bulk_write, fts_finalize_after_text_indexing}; use crate::file_handling::{fts_begin_bulk_write, fts_finalize_after_text_indexing};
@ -429,7 +435,9 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
if let Some(hmac) = spec.hmac { if let Some(hmac) = spec.hmac {
crate::db::set_hmac_mode_override(hmac); crate::db::set_hmac_mode_override(hmac);
} }
let conn = crate::db::open_or_recreate(path.to_str().unwrap(), &Default::default()).unwrap(); let processing = crate::config::ProcessingConfig::default();
let db_path = path.to_str().unwrap();
let conn = crate::db::open_or_recreate(db_path, &processing).unwrap();
// Before the first row: `pgsz` decides how leaves are built, so setting it // Before the first row: `pgsz` decides how leaves are built, so setting it
// afterwards would only affect segments merged later. // afterwards would only affect segments merged later.
if let Some(pgsz) = spec.pgsz { if let Some(pgsz) = spec.pgsz {
@ -440,10 +448,11 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
.unwrap(); .unwrap();
} }
fts_begin_bulk_write(&conn); fts_begin_bulk_write(&conn);
// As a run's writer has it. The WAL then grows until the closing // As a run's writer has it, which is safe only with the run's own bound
// checkpoint, since nothing here caps it the way a run's writer does. // on the log beside it.
conn.execute_batch("PRAGMA wal_autocheckpoint = 0;") conn.execute_batch("PRAGMA wal_autocheckpoint = 0;")
.unwrap(); .unwrap();
let mut wal = crate::indexing::WalGuard::new(&processing, db_path);
let mut rng = Lcg::new(0x5eed); let mut rng = Lcg::new(0x5eed);
let ext_mix = if spec.ext_mix.is_empty() { let ext_mix = if spec.ext_mix.is_empty() {
EXT_PLAIN EXT_PLAIN
@ -535,10 +544,17 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
} }
if spec.commit_every > 0 && (i + 1) % spec.commit_every == 0 { if spec.commit_every > 0 && (i + 1) % spec.commit_every == 0 {
tx.commit().unwrap(); tx.commit().unwrap();
// Where a run's writer checks, between its rounds' commits.
if wal.due() {
wal.checkpoint(&conn);
}
tx = conn.unchecked_transaction().unwrap(); tx = conn.unchecked_transaction().unwrap();
} }
} }
tx.commit().unwrap(); tx.commit().unwrap();
// The run's first tail checkpoint (`checkpoint_tail`), so the merge
// writes onto an empty log.
wal.checkpoint(&conn);
fts_finalize_after_text_indexing(&conn); fts_finalize_after_text_indexing(&conn);
let files = spec.files as i64; let files = spec.files as i64;
crate::db::repo::set_root_counts(&conn, "/seed", RootCounts { files, fts }).unwrap(); crate::db::repo::set_root_counts(&conn, "/seed", RootCounts { files, fts }).unwrap();
@ -722,10 +738,10 @@ pub struct Arm {
/// reserve, which the header cannot be read without. /// reserve, which the header cannot be read without.
pub hmac: Option<crate::db::schema::HmacMode>, pub hmac: Option<crate::db::schema::HmacMode>,
pub path: PathBuf, pub path: PathBuf,
/// How long [`seed_index`] took: rows, zstd bodies and FTS postings, then /// How long [`seed_index`] took: rows, zstd bodies and FTS postings with
/// the merge, counts, hash index and checkpoint. Not an indexing rate: it /// the run's WAL checkpoints among them, then the merge, counts, hash
/// has no walk or extraction, its WAL grows uncapped until the closing /// index and closing checkpoint. Not an indexing rate: it has no walk or
/// checkpoint, and each arm is one sample taken in sweep order. /// extraction, and each arm is one sample taken in sweep order.
pub seeded_in: std::time::Duration, pub seeded_in: std::time::Duration,
} }

View file

@ -19,7 +19,7 @@ use quicksearch_core::db::schema::{
use quicksearch_core::testutil::{measurement_key, seed_index, Scratch, SeedSpec}; use quicksearch_core::testutil::{measurement_key, seed_index, Scratch, SeedSpec};
/// Enough files that the recommendation clears the floor and is derived rather /// Enough files that the recommendation clears the floor and is derived rather
/// than clamped: at 168 B/file, 16 MiB is reached around 100k. `seed_index` /// than clamped: at 150 B/file, 16 MiB is reached around 112k. `seed_index`
/// stores that count, as a run over the index would. Seeded without content: /// stores that count, as a run over the index would. Seeded without content:
/// the FTS write is the slow part and this measures nothing about it. /// the FTS write is the slow part and this measures nothing about it.
const FILES: usize = 150_000; const FILES: usize = 150_000;
@ -128,8 +128,8 @@ fn the_search_connection_opens_at_the_ceiling_it_was_sized_for() {
db::set_search_cache_override(None); db::set_search_cache_override(None);
// --- plain, automatic ------------------------------------------------- // --- plain, automatic -------------------------------------------------
// Same corpus, no key: the sweep found no knee unencrypted, so this must // Same corpus, no key: an unencrypted index has no cliff and is held at a
// be flat regardless of how large the index is. // flat ceiling by choice, for memory, however large the index is.
let (_plain_dir, plain) = Scratch::db("searchcache-plain"); let (_plain_dir, plain) = Scratch::db("searchcache-plain");
let plain_path = plain.to_string_lossy().into_owned(); let plain_path = plain.to_string_lossy().into_owned();
db::set_process_key(None); db::set_process_key(None);

View file

@ -228,11 +228,23 @@ mod imp {
return Signal::Unanswered; return Signal::Unanswered;
} }
// The reply distinguishes "delivered" from "wrote into a // The reply distinguishes "delivered" from "wrote into a
// socket nobody reads". EOF means the peer went away. // socket nobody reads". EOF means the peer went away. A read cut
// short by a signal is retried for the rest of the timeout: with
// SO_RCVTIMEO set, Linux fails it with EINTR after a stop and
// continue even when no handler is installed.
let deadline = std::time::Instant::now() + IO_TIMEOUT;
let mut ack = [0u8; 1]; let mut ack = [0u8; 1];
match stream.read(&mut ack) { loop {
Ok(1) => Signal::Delivered, match stream.read(&mut ack) {
_ => Signal::Unanswered, Ok(1) => return Signal::Delivered,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() || stream.set_read_timeout(Some(left)).is_err() {
return Signal::Unanswered;
}
}
_ => return Signal::Unanswered,
}
} }
} }

View file

@ -304,11 +304,7 @@ impl QuickSearchApp {
fn start_duplicates_scan(&mut self, ctx: &egui::Context) { fn start_duplicates_scan(&mut self, ctx: &egui::Context) {
self.dups.state = DupState::Loading; self.dups.state = DupState::Loading;
let cfg = self.cfg.clone(); let cfg = self.cfg.clone();
let limit = self.backend.start_duplicates(&cfg, ctx.clone()); self.backend.start_duplicates(&cfg, ctx.clone());
// 0 means a scan was already running, whose limit still stands.
if limit > 0 {
self.dups.scan_limit = limit;
}
} }
/// Drops a duplicate listing the index has moved out from under (a finished /// Drops a duplicate listing the index has moved out from under (a finished
@ -317,7 +313,7 @@ impl QuickSearchApp {
fn invalidate_duplicates(&mut self, ctx: &egui::Context) { fn invalidate_duplicates(&mut self, ctx: &egui::Context) {
match dup_invalidation(self.tab, &self.dups.state) { match dup_invalidation(self.tab, &self.dups.state) {
DupInvalidation::Nothing => {} DupInvalidation::Nothing => {}
DupInvalidation::WhenItLands => self.rescan_when_dups_land = true, DupInvalidation::WhenItLands => self.rescan_after_dup_scan(),
DupInvalidation::Rescan => self.start_duplicates_scan(ctx), DupInvalidation::Rescan => self.start_duplicates_scan(ctx),
DupInvalidation::Drop => self.dups.state = DupState::NotLoaded, DupInvalidation::Drop => self.dups.state = DupState::NotLoaded,
} }
@ -332,13 +328,20 @@ impl QuickSearchApp {
fn drop_duplicates(&mut self) { fn drop_duplicates(&mut self) {
match dup_invalidation(self.tab, &self.dups.state) { match dup_invalidation(self.tab, &self.dups.state) {
DupInvalidation::Nothing => {} DupInvalidation::Nothing => {}
DupInvalidation::WhenItLands => self.rescan_when_dups_land = true, DupInvalidation::WhenItLands => self.rescan_after_dup_scan(),
DupInvalidation::Rescan | DupInvalidation::Drop => { DupInvalidation::Rescan | DupInvalidation::Drop => {
self.dups.state = DupState::NotLoaded self.dups.state = DupState::NotLoaded
} }
} }
} }
/// The running scan's answer is stale: cut its tail check short, and scan
/// again once it lands.
fn rescan_after_dup_scan(&mut self) {
self.rescan_when_dups_land = true;
self.backend.cancel_duplicates();
}
/// Every switch goes through here, including guard-delayed ones. /// Every switch goes through here, including guard-delayed ones.
fn switch_tab(&mut self, ctx: &egui::Context, to: Tab) { fn switch_tab(&mut self, ctx: &egui::Context, to: Tab) {
if self.tab == to { if self.tab == to {
@ -546,11 +549,12 @@ impl QuickSearchApp {
} }
self.backend.reindex_live_paths(touched); self.backend.reindex_live_paths(touched);
// Duplicates worker. // Duplicates worker.
if let Some(rx) = &self.backend.dup_job { if let Some(job) = &self.backend.dup_job {
use std::sync::mpsc::TryRecvError; use std::sync::mpsc::TryRecvError;
let done = match rx.try_recv() { let done = match job.rx.try_recv() {
Ok(Ok(groups)) => Some(DupState::Loaded(crate::duplicates_tab::LoadedGroups::new( Ok(Ok(scan)) => Some(DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(
groups, scan.groups,
scan.limited_to,
))), ))),
Ok(Err(e)) => Some(DupState::Error(e)), Ok(Err(e)) => Some(DupState::Error(e)),
Err(TryRecvError::Empty) => None, Err(TryRecvError::Empty) => None,

View file

@ -166,7 +166,7 @@ fn only_an_empty_duplicates_tab_scans_on_arrival() {
)); ));
for held in [ for held in [
DupState::Loading, DupState::Loading,
DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(Vec::new())), DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(Vec::new(), None)),
DupState::Error("boom".into()), DupState::Error("boom".into()),
] { ] {
assert!( assert!(
@ -190,7 +190,7 @@ fn only_an_empty_duplicates_tab_scans_on_arrival() {
/// index can change out from under it has to reach the tab. /// index can change out from under it has to reach the tab.
#[test] #[test]
fn an_index_that_changed_invalidates_what_the_tab_holds() { fn an_index_that_changed_invalidates_what_the_tab_holds() {
let loaded = || DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(Vec::new())); let loaded = || DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(Vec::new(), None));
// On screen: the user is looking at it, so redo it now. // On screen: the user is looking at it, so redo it now.
assert_eq!( assert_eq!(

View file

@ -1,6 +1,6 @@
//! Byte-for-byte verification of one duplicate group, and the modal that //! Byte-for-byte verification of one duplicate group, and the modal that
//! reports it. Grouping only hashes each file's size and head, so this is the //! reports it. Grouping only hashes each file's size, head and tail, so this
//! second opinion, asked for before what usually follows it: deletion. //! is the second opinion, asked for before what usually follows it: deletion.
use super::*; use super::*;
@ -173,8 +173,8 @@ pub(crate) fn verify_modal(ctx: &egui::Context, modal: &VerifyModal) -> bool {
ui.colored_label(color, summary_line(report)); ui.colored_label(color, summary_line(report));
if !identical { if !identical {
ui.label( ui.label(
"Grouping only reads each file's size and how it begins; \ "Grouping only reads each file's size and how it begins and \
this read every byte.", ends; this read every byte.",
); );
// Answers the next question: the files look the same when // Answers the next question: the files look the same when
// opened, so how can they differ? Named formats, because // opened, so how can they differ? Named formats, because

View file

@ -3,7 +3,8 @@
//! //!
//! The duplicates scan and the verification run on throwaway threads fired //! The duplicates scan and the verification run on throwaway threads fired
//! by user actions. The verification can hold a large group's worth of file //! by user actions. The verification can hold a large group's worth of file
//! handles, so it carries a cancel flag and shutdown raises it. //! handles and the scan reads a file per listed member, so each carries a
//! cancel flag and shutdown raises both.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@ -12,7 +13,9 @@ use std::sync::{mpsc, Arc};
use quicksearch_core::config::Config; use quicksearch_core::config::Config;
use quicksearch_core::coordinator::IndexCoordinator; use quicksearch_core::coordinator::IndexCoordinator;
use quicksearch_core::live::{LiveUpdate, LiveWatcher}; use quicksearch_core::live::{LiveUpdate, LiveWatcher};
use quicksearch_core::search::{DuplicateGroup, SearchService, SearchUpdate}; use quicksearch_core::search::{
find_duplicate_groups, split_by_tail, DuplicateGroup, SearchService, SearchUpdate,
};
use quicksearch_core::shutdown; use quicksearch_core::shutdown;
use quicksearch_core::verify::{verify_identical, VerifyUpdate}; use quicksearch_core::verify::{verify_identical, VerifyUpdate};
@ -29,9 +32,32 @@ impl VerifyJob {
} }
} }
/// A finished duplicates scan.
pub struct DupScan {
pub groups: Vec<DuplicateGroup>,
/// The fetch came back full at this many groups, so the index may hold
/// more. Taken before the tail check, which can leave fewer listed.
pub limited_to: Option<u32>,
}
/// A duplicates scan in flight. It sends one answer, once the tail check is
/// done, so no group reaches the tab on its head hash alone. Cancelling drops
/// the members the tail check has not reached, so an answer about to be
/// discarded lands sooner.
pub struct DupJob {
pub rx: mpsc::Receiver<Result<DupScan, String>>,
cancel: Arc<AtomicBool>,
}
impl DupJob {
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
/// Duplicate groups a scan is asked for. Every group listed costs one row /// Duplicate groups a scan is asked for. Every group listed costs one row
/// fetch and one member query to hydrate, so this is a real cost, not a /// fetch and one member query to hydrate, and a file read per member for the
/// display cap. /// tail check, so this is a real cost, not a display cap.
pub const DUP_SCAN_LIMIT: u32 = 500; pub const DUP_SCAN_LIMIT: u32 = 500;
/// …and the most the hidden set may add to it. /// …and the most the hidden set may add to it.
const DUP_HIDDEN_ALLOWANCE: u32 = 4_500; const DUP_HIDDEN_ALLOWANCE: u32 = 4_500;
@ -49,7 +75,7 @@ pub struct Backend {
pub coordinator: Arc<IndexCoordinator>, pub coordinator: Arc<IndexCoordinator>,
pub search: Option<SearchService>, pub search: Option<SearchService>,
pub search_rx: mpsc::Receiver<SearchUpdate>, pub search_rx: mpsc::Receiver<SearchUpdate>,
pub dup_job: Option<mpsc::Receiver<Result<Vec<DuplicateGroup>, String>>>, pub dup_job: Option<DupJob>,
pub verify_job: Option<VerifyJob>, pub verify_job: Option<VerifyJob>,
/// Watches the on-screen results; `None` only after [`Backend::shutdown`]. /// Watches the on-screen results; `None` only after [`Backend::shutdown`].
pub live: Option<LiveWatcher>, pub live: Option<LiveWatcher>,
@ -152,24 +178,21 @@ impl Backend {
} }
/// Ignored while a scan is already running: a second one would re-read the /// Ignored while a scan is already running: a second one would re-read the
/// whole hash index for an answer the first is about to produce. Returns /// whole hash index for an answer the first is about to produce.
/// the limit it asked for, which tells the tab a full page from a pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) {
/// complete one; 0 when nothing was started.
pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) -> u32 {
if self.dup_job.is_some() { if self.dup_job.is_some() {
return 0; return;
} }
let limit = dup_scan_limit(config.duplicates.hidden_groups.len()); let limit = dup_scan_limit(config.duplicates.hidden_groups.len());
let (tx, rx) = mpsc::channel(); self.dup_job = Some(spawn_dup_scan(config.clone(), limit, ctx));
let db = config.resolved_database_path(); }
std::thread::spawn(move || {
let result = /// Hurry the running duplicates scan, if any, whose answer is about to be
quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), limit); /// discarded. It still lands, and `dup_job` clears then.
let _ = tx.send(result); pub fn cancel_duplicates(&self) {
ctx.request_repaint(); if let Some(job) = &self.dup_job {
}); job.cancel();
self.dup_job = Some(rx); }
limit
} }
/// Compare every member against the first, byte for byte, on a worker /// Compare every member against the first, byte for byte, on a worker
@ -205,6 +228,7 @@ impl Backend {
/// holds the closing window about a second, never indefinitely. /// holds the closing window about a second, never indefinitely.
pub fn shutdown(&mut self) { pub fn shutdown(&mut self) {
self.cancel_verify(); self.cancel_verify();
self.cancel_duplicates();
if let Some(search) = self.search.take() { if let Some(search) = self.search.take() {
search.shutdown(); search.shutdown();
} }
@ -214,3 +238,153 @@ impl Backend {
self.coordinator.shutdown(); self.coordinator.shutdown();
} }
} }
/// Run [`scan_duplicates`] on a thread of its own, which sends its answer and
/// nothing else.
fn spawn_dup_scan(config: Config, limit: u32, ctx: egui::Context) -> DupJob {
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
std::thread::spawn(move || {
let _ = tx.send(scan_duplicates(&config, limit, &worker_cancel));
ctx.request_repaint();
});
DupJob { rx, cancel }
}
/// One duplicates scan, as its thread runs it: the `limit` groups wasting the
/// most, then the tail check on just those. Groups it dissolves are not
/// replaced, so fewer than `limit` may be listed.
fn scan_duplicates(config: &Config, limit: u32, cancel: &AtomicBool) -> Result<DupScan, String> {
let db = config.resolved_database_path();
let groups = find_duplicate_groups(&db.to_string_lossy(), limit)?;
let limited_to = (groups.len() as u32 >= limit).then_some(limit);
// See `watch_live` for why the index must not be opened.
let groups = split_by_tail(
groups,
config.processing.hash_length,
|path| config.is_index_file(path),
cancel,
);
Ok(DupScan { groups, limited_to })
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc::RecvTimeoutError;
use std::time::Duration;
use quicksearch_core::indexing::{IndexingService, IndexingStatus};
use quicksearch_core::search::duplicates::Member;
use quicksearch_core::testutil::{touch, wait_for, Scratch};
/// Each group's member names, in listed order.
fn names<'a>(groups: impl IntoIterator<Item = &'a Vec<Member>>) -> Vec<Vec<&'a str>> {
groups
.into_iter()
.map(|members| members.iter().map(|m| m.1.as_str()).collect())
.collect()
}
fn listed(groups: &[DuplicateGroup]) -> Vec<Vec<&str>> {
names(groups.iter().map(|g| &g.members))
}
/// An indexed root holding two pairs whose members share a head: `fake_*`,
/// whose tails differ, and `real_*`, real copies. The scratch guards come
/// back with the config.
fn indexed_pairs(tag: &str) -> (Scratch, Scratch, Config) {
let root = Scratch::canonical(&format!("{tag}-root"));
let (db_dir, db) = Scratch::db(&format!("{tag}-db"));
let mut config = Config::default();
config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()];
config.paths.database_path = db.to_string_lossy().into_owned();
config.processing.hash_length = 1024;
// The biggest waste by head alone, but the tails differ.
let mut fake = vec![b'f'; 4000];
touch(&root.join("fake_a.bin"), &fake);
fake[3999] = b'g';
touch(&root.join("fake_b.bin"), &fake);
touch(&root.join("real_a.bin"), &[b'r'; 3000]);
touch(&root.join("real_b.bin"), &[b'r'; 3000]);
let service = IndexingService::new();
service
.start_indexing(
config.paths.indexing_paths.clone(),
config.paths.database_path.clone(),
config.clone(),
)
.unwrap();
wait_for("the run to end", Duration::from_secs(120), || {
matches!(
service.get_status(),
IndexingStatus::Idle | IndexingStatus::Error(_)
)
});
assert!(matches!(service.get_status(), IndexingStatus::Idle));
let head_only = find_duplicate_groups(&config.paths.database_path, 10).unwrap();
assert_eq!(
names(head_only.iter().map(|g| &g.members)),
[["fake_a.bin", "fake_b.bin"], ["real_a.bin", "real_b.bin"]],
"the head alone groups both pairs"
);
(root, db_dir, config)
}
/// The tail check reads only the groups the fetch returned: one it
/// dissolves leaves a gap rather than pulling in the next group.
#[test]
fn the_scan_tail_checks_only_the_groups_it_fetched() {
let (_root, _db_dir, config) = indexed_pairs("dup-scan");
let cancel = AtomicBool::new(false);
let top = scan_duplicates(&config, 1, &cancel).unwrap();
assert!(
top.groups.is_empty(),
"the one group fetched was dissolved and not replaced: {:?}",
listed(&top.groups)
);
assert_eq!(top.limited_to, Some(1), "the fetch itself was full");
let all = scan_duplicates(&config, 10, &cancel).unwrap();
assert_eq!(listed(&all.groups), [["real_a.bin", "real_b.bin"]]);
assert_eq!(all.limited_to, None);
}
/// The scan thread sends the tab one answer, the tail-checked one, and
/// then hangs up: no head-only group is ever on the channel, even for a
/// moment, and a cancelled scan lists nothing it did not check.
#[test]
fn nothing_reaches_the_tab_before_the_tail_check() {
let (_root, _db_dir, config) = indexed_pairs("dup-send");
let head_only = find_duplicate_groups(&config.paths.database_path, 10).unwrap();
let checked = split_by_tail(
head_only,
config.processing.hash_length,
|_| false,
&AtomicBool::new(false),
);
let job = spawn_dup_scan(config.clone(), 10, egui::Context::default());
let first = job.rx.recv_timeout(Duration::from_secs(60));
let sent = first.expect("the scan sent nothing").unwrap();
assert_eq!(sent.groups, checked, "what was sent was not checked");
assert_eq!(listed(&sent.groups), [["real_a.bin", "real_b.bin"]]);
assert!(
matches!(
job.rx.recv_timeout(Duration::from_secs(60)),
Err(RecvTimeoutError::Disconnected)
),
"the scan sent a second message"
);
let cancelled = scan_duplicates(&config, 10, &AtomicBool::new(true)).unwrap();
assert!(
cancelled.groups.is_empty(),
"a cancelled scan listed what it never read: {:?}",
listed(&cancelled.groups)
);
}
}

View file

@ -1,14 +1,17 @@
//! The Duplicates tab: groups of files sharing a content hash. //! The Duplicates tab: groups of files sharing a content hash.
//! //!
//! Grouping is a suspicion, not a verdict: the hash covers each file's size //! Grouping is a suspicion, not a verdict: the hash covers each file's size
//! and its first `processing.hash_length` bytes and nothing else. The tab says //! and its first `processing.hash_length` bytes, and the scan then splits
//! groups whose members end differently and leaves out any member it cannot
//! read (`search::split_by_tail`); nothing in between is read. The tab says
//! so where someone about to delete something will read it, and offers the //! so where someone about to delete something will read it, and offers the
//! byte-for-byte verification that settles it. //! byte-for-byte verification that settles it.
//! //!
//! Two ways to stop seeing a group again: exclude paths by glob (a folder that //! Two ways to stop seeing a group again: exclude paths by glob (a folder that
//! is *meant* to hold copies), or hide one group by its hash (a head-hash //! is *meant* to hold copies), or hide one group by its identity, head hash
//! false positive). Both are persisted, and both are applied to the listing //! and tail digest (a false positive that differs only in between). Both are
//! already in hand rather than by going back to the database. //! persisted, and both are applied to the listing already in hand rather than
//! by going back to the database.
use std::collections::HashSet; use std::collections::HashSet;
@ -55,18 +58,24 @@ fn extension_of(name: &str) -> String {
} }
/// What [`LoadedGroups::rebuild`] orders an extension listing by: no extension /// What [`LoadedGroups::rebuild`] orders an extension listing by: no extension
/// last, then the extension, then the biggest waste, then the hash to settle /// last, then the extension, then the biggest waste, then the identity to
/// whatever is left. /// settle whatever is left.
type ExtensionKey<'a> = (bool, &'a str, std::cmp::Reverse<i64>, &'a [u8]); type ExtensionKey<'a> = (
bool,
&'a str,
std::cmp::Reverse<i64>,
(&'a [u8], &'a [u8; 32]),
);
fn extension_key<'a>(row: &'a Row, groups: &'a [DuplicateGroup]) -> ExtensionKey<'a> { fn extension_key<'a>(row: &'a Row, groups: &'a [DuplicateGroup]) -> ExtensionKey<'a> {
let group = &groups[row.group];
( (
// Extensionless groups last: a category of their own, and not an // Extensionless groups last: a category of their own, and not an
// interesting one. // interesting one.
row.extension.is_empty(), row.extension.is_empty(),
row.extension.as_str(), row.extension.as_str(),
std::cmp::Reverse(row.redundant), std::cmp::Reverse(row.redundant),
groups[row.group].hash.as_slice(), (group.hash.as_slice(), &group.tail),
) )
} }
@ -131,18 +140,18 @@ impl DupFilters {
} }
} }
fn hide(&mut self, hash_hex: String) { fn hide(&mut self, identity: String) {
if self.hidden.contains(&hash_hex) { if self.hidden.contains(&identity) {
return; return;
} }
self.config.hidden_groups.push(hash_hex); self.config.hidden_groups.push(identity);
self.recompile(); self.recompile();
} }
fn unhide(&mut self, hash_hex: &str) { fn unhide(&mut self, identity: &str) {
self.config self.config
.hidden_groups .hidden_groups
.retain(|h| !h.trim().eq_ignore_ascii_case(hash_hex)); .retain(|h| !h.trim().eq_ignore_ascii_case(identity));
self.recompile(); self.recompile();
} }
@ -156,7 +165,7 @@ impl DupFilters {
} }
fn is_hidden(&self, group: &DuplicateGroup) -> bool { fn is_hidden(&self, group: &DuplicateGroup) -> bool {
!self.hidden.is_empty() && self.hidden.contains(&group.hash_hex()) !self.hidden.is_empty() && self.hidden.contains(&group.identity())
} }
} }
@ -200,6 +209,9 @@ fn totals(group: &DuplicateGroup, members: &[usize]) -> (i64, i64, i64) {
/// frame, on a list that repaints at 20 Hz for as long as the tab is open. /// frame, on a list that repaints at 20 Hz for as long as the tab is open.
pub struct LoadedGroups { pub struct LoadedGroups {
groups: Vec<DuplicateGroup>, groups: Vec<DuplicateGroup>,
/// The scan's fetch came back full at this many groups: there may be
/// more. See [`crate::backend::DupScan`].
limited_to: Option<u32>,
/// The visible groups, in display order. /// The visible groups, in display order.
rows: Vec<Row>, rows: Vec<Row>,
/// Groups the filters kept off screen, for the line that says so. /// Groups the filters kept off screen, for the line that says so.
@ -209,9 +221,10 @@ pub struct LoadedGroups {
} }
impl LoadedGroups { impl LoadedGroups {
pub fn new(groups: Vec<DuplicateGroup>) -> LoadedGroups { pub fn new(groups: Vec<DuplicateGroup>, limited_to: Option<u32>) -> LoadedGroups {
LoadedGroups { LoadedGroups {
groups, groups,
limited_to,
rows: Vec::new(), rows: Vec::new(),
filtered_out: 0, filtered_out: 0,
built_for: None, built_for: None,
@ -287,9 +300,6 @@ pub struct DuplicatesTab {
pub filters: DupFilters, pub filters: DupFilters,
/// List the groups the filters hid, so hiding one is reversible. /// List the groups the filters hid, so hiding one is reversible.
show_hidden: bool, show_hidden: bool,
/// What the last scan was asked for; a result of exactly this many groups
/// is a truncated one. See [`crate::backend::Backend::start_duplicates`].
pub scan_limit: u32,
/// The exclusion being typed. /// The exclusion being typed.
draft: String, draft: String,
} }
@ -307,7 +317,8 @@ pub struct DuplicatesActions {
const VERIFY_LABEL: &str = "Verify copies are identical…"; const VERIFY_LABEL: &str = "Verify copies are identical…";
const VERIFY_TIP: &str = "Reads every file in the group in full and compares them byte for \ const VERIFY_TIP: &str = "Reads every file in the group in full and compares them byte for \
byte. Grouping only reads each file's size and how it begins."; byte. Grouping only reads each file's size and how it begins \
and ends.";
const HIDE_LABEL: &str = "Hide this group"; const HIDE_LABEL: &str = "Hide this group";
const UNHIDE_LABEL: &str = "Unhide this group"; const UNHIDE_LABEL: &str = "Unhide this group";
const NO_GROUPS: &str = "No duplicate files found."; const NO_GROUPS: &str = "No duplicate files found.";
@ -331,7 +342,6 @@ impl DuplicatesTab {
sort: DupSort::default(), sort: DupSort::default(),
filters: DupFilters::new(filters), filters: DupFilters::new(filters),
show_hidden: false, show_hidden: false,
scan_limit: 0,
draft: String::new(), draft: String::new(),
} }
} }
@ -415,19 +425,19 @@ impl DuplicatesTab {
} }
DupState::Loaded(loaded) => { DupState::Loaded(loaded) => {
// "Limited to", not "showing": the scan asks for extra to // "Limited to", not "showing": the scan asks for extra to
// cover the hidden set, so the number it found is not always // cover the hidden set, and the tail check dissolves some of
// the number on screen. The filtered-out line below accounts // what it found, so the number is not the number on screen.
// for the difference. // The filtered-out line below accounts for the hidden ones.
if loaded.groups.len() as u32 >= self.scan_limit && self.scan_limit > 0 { if let Some(limit) = loaded.limited_to {
ui.label(hint(match self.sort { ui.label(hint(match self.sort {
DupSort::Reclaimable => { DupSort::Reclaimable => {
format!("Limited to the {} largest groups.", self.scan_limit) format!("Limited to the {} largest groups.", limit)
} }
// Said plainly: this is not every .raw file you own, // Said plainly: this is not every .raw file you own,
// it is the biggest groups put in that order. // it is the biggest groups put in that order.
DupSort::Extension => format!( DupSort::Extension => format!(
"Limited to the {} largest groups, then ordered by extension.", "Limited to the {} largest groups, then ordered by extension.",
self.scan_limit limit
), ),
})); }));
} }
@ -443,8 +453,10 @@ impl DuplicatesTab {
.show(ui, |ui| { .show(ui, |ui| {
for row in &loaded.rows { for row in &loaded.rows {
let group = &loaded.groups[row.group]; let group = &loaded.groups[row.group];
// Keyed by identity, so a group stays open
// through a rescan that lists it elsewhere.
let header = egui::CollapsingHeader::new(&row.title) let header = egui::CollapsingHeader::new(&row.title)
.id_salt(row.group) .id_salt((&group.hash, &group.tail))
.show(ui, |ui| { .show(ui, |ui| {
for &j in &row.members { for &j in &row.members {
member_row(ui, group, row, j, verify_open, &mut picked); member_row(ui, group, row, j, verify_open, &mut picked);
@ -463,12 +475,12 @@ impl DuplicatesTab {
match picked { match picked {
Some(Pick::Verify(paths)) => actions.verify = Some(paths), Some(Pick::Verify(paths)) => actions.verify = Some(paths),
Some(Pick::Hide(hex)) => { Some(Pick::Hide(identity)) => {
self.filters.hide(hex); self.filters.hide(identity);
edited = true; edited = true;
} }
Some(Pick::Unhide(hex)) => { Some(Pick::Unhide(identity)) => {
self.filters.unhide(&hex); self.filters.unhide(&identity);
edited = true; edited = true;
} }
Some(Pick::Exclude(pattern)) => { Some(Pick::Exclude(pattern)) => {
@ -585,10 +597,10 @@ fn caution_banner(ui: &mut egui::Ui, hash_length: usize) {
ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().item_spacing.x = 0.0;
ui.label(egui::RichText::new("These are suspected duplicates. ").color(p.orange)); ui.label(egui::RichText::new("These are suspected duplicates. ").color(p.orange));
ui.label(format!( ui.label(format!(
"Files are grouped by their size and their first {}, which is all \ "Files are grouped by their size and their first {0}, which is all \
indexing reads. That is not proof that they are identical: \ indexing reads, then split apart where their last {0} differ. That \
right-click a group and choose \"{}\" to compare every byte before \ is not proof that they are identical: right-click a group and \
you delete anything.", choose \"{1}\" to compare every byte before you delete anything.",
human_size(hash_length as u64), human_size(hash_length as u64),
VERIFY_LABEL.trim_end_matches('…'), VERIFY_LABEL.trim_end_matches('…'),
)); ));
@ -646,11 +658,11 @@ fn group_menu(
} }
ui.separator(); ui.separator();
if hide_entry(ui, row.hidden) { if hide_entry(ui, row.hidden) {
let hex = group.hash_hex(); let identity = group.identity();
*picked = Some(if row.hidden { *picked = Some(if row.hidden {
Pick::Unhide(hex) Pick::Unhide(identity)
} else { } else {
Pick::Hide(hex) Pick::Hide(identity)
}); });
} }
} }

View file

@ -1,5 +1,11 @@
use super::*; use super::*;
use std::sync::atomic::AtomicBool;
use quicksearch_core::config::Config;
use quicksearch_core::search::{split_by_tail, HeadGroup};
use quicksearch_core::testutil::Scratch;
use crate::test_ui::{click_at, click_with, painted_text, painted_text_center, run_panel}; use crate::test_ui::{click_at, click_with, painted_text, painted_text_center, run_panel};
const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
@ -9,6 +15,7 @@ const HASH_LENGTH: usize = 8192;
fn group(paths: &[&str]) -> DuplicateGroup { fn group(paths: &[&str]) -> DuplicateGroup {
DuplicateGroup { DuplicateGroup {
hash: vec![0xab; 32], hash: vec![0xab; 32],
tail: [0xcd; 32],
members: paths members: paths
.iter() .iter()
.enumerate() .enumerate()
@ -22,8 +29,7 @@ fn group(paths: &[&str]) -> DuplicateGroup {
fn tab(groups: Vec<DuplicateGroup>, filters: DuplicatesConfig) -> DuplicatesTab { fn tab(groups: Vec<DuplicateGroup>, filters: DuplicatesConfig) -> DuplicatesTab {
let mut tab = DuplicatesTab::new(&filters); let mut tab = DuplicatesTab::new(&filters);
tab.scan_limit = crate::backend::DUP_SCAN_LIMIT; tab.state = DupState::Loaded(LoadedGroups::new(groups, None));
tab.state = DupState::Loaded(LoadedGroups::new(groups));
tab tab
} }
@ -214,6 +220,22 @@ fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
); );
} }
/// Whether the listing was cut short is the fetch's to say: the tail check
/// can leave a full fetch listing fewer groups than it asked for.
#[test]
fn a_full_fetch_says_it_was_limited_however_many_are_listed() {
let ctx = crate::test_ui::ctx();
let limited = |limited_to: Option<u32>| {
let mut tab = DuplicatesTab::new(&DuplicatesConfig::default());
tab.state = DupState::Loaded(LoadedGroups::new(vec![group(&PATHS)], limited_to));
painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0)
.iter()
.any(|t| t.contains("Limited to the 500 largest groups"))
};
assert!(limited(Some(500)), "one group left of a full fetch");
assert!(!limited(None), "a fetch short of its limit was everything");
}
// --- Ordering --------------------------------------------------------------- // --- Ordering ---------------------------------------------------------------
/// A group named by its first member, with the waste that decides the /// A group named by its first member, with the waste that decides the
@ -240,7 +262,7 @@ fn names(loaded: &LoadedGroups) -> Vec<String> {
/// The names of the groups, in the order the tab would list them. /// The names of the groups, in the order the tab would list them.
fn listed(groups: Vec<DuplicateGroup>, sort: DupSort) -> Vec<String> { fn listed(groups: Vec<DuplicateGroup>, sort: DupSort) -> Vec<String> {
let filters = DupFilters::new(&DuplicatesConfig::default()); let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups); let mut loaded = LoadedGroups::new(groups, None);
loaded.rebuild(sort, &filters, false); loaded.rebuild(sort, &filters, false);
names(&loaded) names(&loaded)
} }
@ -312,7 +334,7 @@ fn the_order_returns_when_the_choice_does() {
sized_group("middling.txt", 100), sized_group("middling.txt", 100),
]; ];
let filters = DupFilters::new(&DuplicatesConfig::default()); let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups); let mut loaded = LoadedGroups::new(groups, None);
loaded.rebuild(DupSort::Extension, &filters, false); loaded.rebuild(DupSort::Extension, &filters, false);
loaded.rebuild(DupSort::Reclaimable, &filters, false); loaded.rebuild(DupSort::Reclaimable, &filters, false);
assert_eq!(names(&loaded), ["big.txt", "small.jpg", "middling.txt"]); assert_eq!(names(&loaded), ["big.txt", "small.jpg", "middling.txt"]);
@ -541,15 +563,15 @@ fn an_unparseable_pattern_is_reported_rather_than_silently_dropped() {
fn hiding_a_group_takes_it_off_the_list_and_is_persisted() { fn hiding_a_group_takes_it_off_the_list_and_is_persisted() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS); let mut tab = loaded(&PATHS);
let hash = group(&PATHS).hash_hex(); let identity = group(&PATHS).identity();
let header = header_of(&ctx, &mut tab); let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header); context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, HIDE_LABEL); let actions = click_menu_entry(&ctx, &mut tab, HIDE_LABEL);
assert_eq!( assert_eq!(
actions.save_filters.map(|f| f.hidden_groups), actions.save_filters.map(|f| f.hidden_groups),
Some(vec![hash]), Some(vec![identity]),
"hiding is not remembered by content hash" "hiding is not remembered by head and tail"
); );
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0); let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}"); assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
@ -566,7 +588,7 @@ fn a_group_hidden_in_the_config_is_not_listed() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = tab( let mut tab = tab(
vec![group(&PATHS), sized_group("keep.txt", 40)], vec![group(&PATHS), sized_group("keep.txt", 40)],
hiding(&[group(&PATHS).hash_hex()]), hiding(&[group(&PATHS).identity()]),
); );
let out = frame(&ctx, &mut tab, false, Vec::new()).0; let out = frame(&ctx, &mut tab, false, Vec::new()).0;
let listed = headers(&out); let listed = headers(&out);
@ -584,7 +606,7 @@ fn a_group_hidden_in_the_config_is_not_listed() {
#[test] #[test]
fn show_hidden_lists_them_again_and_offers_the_way_back() { fn show_hidden_lists_them_again_and_offers_the_way_back() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], hiding(&[group(&PATHS).hash_hex()])); let mut tab = tab(vec![group(&PATHS)], hiding(&[group(&PATHS).identity()]));
let (out, _) = frame(&ctx, &mut tab, false, Vec::new()); let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let toggle = painted_text_center(&out, "Show hidden").expect("no Show hidden control"); let toggle = painted_text_center(&out, "Show hidden").expect("no Show hidden control");
let (out, _) = frame(&ctx, &mut tab, false, click_at(toggle)); let (out, _) = frame(&ctx, &mut tab, false, click_at(toggle));
@ -610,7 +632,7 @@ fn clear_empties_the_hidden_list() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = tab( let mut tab = tab(
vec![group(&PATHS)], vec![group(&PATHS)],
hiding(&[group(&PATHS).hash_hex(), "00".repeat(32)]), hiding(&[group(&PATHS).identity(), "00".repeat(64)]),
); );
let (out, _) = frame(&ctx, &mut tab, false, Vec::new()); let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let clear = painted_text_center(&out, "Clear").expect("no Clear button"); let clear = painted_text_center(&out, "Clear").expect("no Clear button");
@ -622,18 +644,108 @@ fn clear_empties_the_hidden_list() {
assert_eq!(headers(&out).len(), 1, "the group did not come back"); assert_eq!(headers(&out).len(), 1, "the group did not come back");
} }
/// A hash spelled in capitals by a hand-edited config still means that group. /// An identity spelled in capitals by a hand-edited config still means that
/// group.
#[test] #[test]
fn hidden_hashes_are_matched_case_insensitively() { fn hidden_identities_are_matched_case_insensitively() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = tab( let mut tab = tab(
vec![group(&PATHS)], vec![group(&PATHS)],
hiding(&[group(&PATHS).hash_hex().to_uppercase()]), hiding(&[group(&PATHS).identity().to_uppercase()]),
); );
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0); let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}"); assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
} }
/// A head hash alone, as earlier versions saved a hidden group, names no
/// group now: every listed group is a head and a tail.
#[test]
fn a_head_only_entry_hides_nothing() {
let ctx = crate::test_ui::ctx();
let head_only = group(&PATHS).identity()[..64].to_string();
let mut tab = tab(vec![group(&PATHS)], hiding(&[head_only]));
let out = frame(&ctx, &mut tab, false, Vec::new()).0;
assert_eq!(headers(&out).len(), 1, "an old entry hid a group");
}
/// The head the files below were "indexed" with.
const TAIL: usize = 16;
/// Two sets of copies under `dir` that start alike and end apart, as the tail
/// check lists them: `a1.bin` and `a2.bin`, then `b1.bin` and `b2.bin`.
fn sharing_a_head(dir: &std::path::Path) -> Vec<DuplicateGroup> {
let members = ["a1.bin", "a2.bin", "b1.bin", "b2.bin"]
.iter()
.enumerate()
.map(|(i, name)| {
let mut body = vec![b'h'; 3 * TAIL];
body[3 * TAIL - 1] = name.as_bytes()[0];
let path = dir.join(name);
std::fs::write(&path, &body).unwrap();
let path = path.to_str().unwrap().to_string();
let size = body.len() as u64;
(i as i64, name.to_string(), path, size, 1_700_000_000)
})
.collect();
let head = HeadGroup {
hash: vec![0xab; 32],
members,
};
split_by_tail(vec![head], TAIL, |_| false, &AtomicBool::new(false))
}
/// Hides whichever group is listed first; what the tab hands back to save.
fn hide_the_first(ctx: &egui::Context, tab: &mut DuplicatesTab) -> DuplicatesConfig {
let header = header_of(ctx, tab);
context_menu_on(ctx, tab, false, &header);
click_menu_entry(ctx, tab, HIDE_LABEL)
.save_filters
.expect("the hide was not handed back to be saved")
}
/// One head hash, two real sets of copies: hiding one leaves the other.
#[test]
fn hiding_one_of_two_groups_sharing_a_head_leaves_the_other() {
let ctx = crate::test_ui::ctx();
let dir = Scratch::dir("dup-tab-shared-head");
let groups = sharing_a_head(&dir);
assert_eq!(groups.len(), 2, "the tail check did not split them");
let mut tab = tab(groups.clone(), DuplicatesConfig::default());
let saved = hide_the_first(&ctx, &mut tab);
assert_eq!(saved.hidden_groups, [groups[0].identity()]);
let listed = headers(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(
listed[0].contains("b1.bin"),
"the wrong one is listed: {listed:?}"
);
}
/// The hidden set is written to the config and read back at the next start,
/// where the identity it holds must still name that group, and only it.
#[test]
fn a_hidden_group_stays_hidden_through_a_saved_config() {
let ctx = crate::test_ui::ctx();
let dir = Scratch::dir("dup-tab-hidden-config");
let groups = sharing_a_head(&dir);
let mut before = tab(groups.clone(), DuplicatesConfig::default());
let path = dir.join("config.toml");
let config = Config {
duplicates: hide_the_first(&ctx, &mut before),
source: Some(path.clone()),
..Config::default()
};
config.save().unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.duplicates.hidden_groups, [groups[0].identity()]);
let mut after = tab(groups, loaded.duplicates);
let listed = headers(&frame(&ctx, &mut after, false, Vec::new()).0);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(listed[0].contains("b1.bin"), "{listed:?}");
}
// --- The two together ------------------------------------------------------- // --- The two together -------------------------------------------------------
/// The extension listing orders on what the titles say, which after an /// The extension listing orders on what the titles say, which after an
@ -654,7 +766,7 @@ fn the_extension_order_follows_the_recomputed_waste() {
); );
let filters = DupFilters::new(&excluding(&["/keep-out/*"])); let filters = DupFilters::new(&excluding(&["/keep-out/*"]));
let mut loaded = LoadedGroups::new(vec![big, small]); let mut loaded = LoadedGroups::new(vec![big, small], None);
loaded.rebuild(DupSort::Extension, &filters, false); loaded.rebuild(DupSort::Extension, &filters, false);
assert_eq!( assert_eq!(
names(&loaded), names(&loaded),
@ -672,7 +784,7 @@ fn the_extension_order_follows_the_recomputed_waste() {
#[test] #[test]
fn an_unchanged_listing_is_not_rebuilt() { fn an_unchanged_listing_is_not_rebuilt() {
let filters = DupFilters::new(&DuplicatesConfig::default()); let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(vec![group(&PATHS)]); let mut loaded = LoadedGroups::new(vec![group(&PATHS)], None);
loaded.rebuild(DupSort::Reclaimable, &filters, false); loaded.rebuild(DupSort::Reclaimable, &filters, false);
let built = loaded.built_for; let built = loaded.built_for;
loaded.rows.clear(); loaded.rows.clear();

View file

@ -341,9 +341,11 @@ fn duplicates_section(ui: &mut egui::Ui) {
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
"A group is a strong suspicion, not a verdict: files are grouped by \ "A group is a strong suspicion, not a verdict: files are grouped by \
their size and a hash of their first few kilobytes, and the rest is \ their size and a hash of their first few kilobytes, then split apart \
never read. Before deleting anything, right-click the group and choose \ where their last few kilobytes differ, and the rest is never read. \
Verify copies are identical to compare every byte.", A file whose end cannot be read is left out. Before deleting \
anything, right-click the group and choose Verify copies are \
identical to compare every byte.",
); );
ui.add_space(8.0); ui.add_space(8.0);

View file

@ -735,13 +735,14 @@ fn search_cache_hint(config: &Config, indexed_files: Option<i64>) -> Option<Stri
let mib = recommended_search_cache_mib(files, keyed); let mib = recommended_search_cache_mib(files, keyed);
if !keyed { if !keyed {
return Some(format!( return Some(format!(
"Automatic: {} MiB. An unencrypted index reads a cache miss \ "Automatic: {} MiB, kept small to save memory. An unencrypted index \
straight from the operating system, so a larger cache is no faster.", reads a cache miss straight from the operating system, so a larger \
cache is at most about a quarter faster.",
mib mib
)); ));
} }
let counted = crate::format::group_thousands(files.max(0) as u64); let counted = crate::format::group_thousands(files.max(0) as u64);
// Past ~800k files the automatic value is capped below what the index // Past ~1.3M files the automatic value is capped below what the index
// wants; saying so makes the override discoverable. // wants; saying so makes the override discoverable.
let wanted = files.max(0).saturating_mul(SEARCH_CACHE_BYTES_PER_FILE) / (1024 * 1024); let wanted = files.max(0).saturating_mul(SEARCH_CACHE_BYTES_PER_FILE) / (1024 * 1024);
if wanted > SEARCH_CACHE_MAX_MIB { if wanted > SEARCH_CACHE_MAX_MIB {

View file

@ -524,14 +524,14 @@ fn the_search_cache_hint_explains_the_automatic_value() {
// came from. // came from.
cfg.security.password_protected = true; cfg.security.password_protected = true;
let keyed = search_cache_hint(&cfg, Some(200_000)).expect("a hint"); let keyed = search_cache_hint(&cfg, Some(200_000)).expect("a hint");
assert!(keyed.contains("32 MiB"), "{}", keyed); assert!(keyed.contains("28 MiB"), "{}", keyed);
assert!(keyed.contains("200,000"), "{}", keyed); assert!(keyed.contains("200,000"), "{}", keyed);
// Encrypted and past it: says so, and says what the index actually wants, // Encrypted and past it: says so, and says what the index actually wants,
// or the override cannot be found by the people who need it. // or the override cannot be found by the people who need it.
let capped = search_cache_hint(&cfg, Some(2_000_000)).expect("a hint"); let capped = search_cache_hint(&cfg, Some(2_000_000)).expect("a hint");
assert!( assert!(
capped.contains("128 MiB") && capped.contains("320 MiB"), capped.contains("192 MiB") && capped.contains("286 MiB"),
"the capped hint must name both the cap and the want: {}", "the capped hint must name both the cap and the want: {}",
capped capped
); );

View file

@ -133,7 +133,9 @@ pub static HASH_LENGTH: Tip = Tip {
body: "How many bytes from the start of each file QuickSearch reads to \ body: "How many bytes from the start of each file QuickSearch reads to \
recognise it. They make the fingerprint the Duplicates tab groups \ recognise it. They make the fingerprint the Duplicates tab groups \
files by, tell what kind of file it is, and for a small text file \ files by, tell what kind of file it is, and for a small text file \
are the whole text.\n\n\ are the whole text. The Duplicates tab also reads as many bytes \
from the end of each file it lists, and splits apart files that \
only start alike.\n\n\
Reading more is more reliable but slower, especially over a \ Reading more is more reliable but slower, especially over a \
network.", network.",
examples: &[ examples: &[
@ -268,13 +270,13 @@ pub static DISPLAY_LIMIT: Tip = Tip {
pub static SEARCH_CACHE: Tip = Tip { pub static SEARCH_CACHE: Tip = Tip {
title: "Search cache", title: "Search cache",
body: "Memory, in MiB, that searching keeps index pages in. 0 sizes it \ body: "Memory, in MiB, that searching keeps index pages in. 0 sizes it \
for your index automatically, up to 128 MiB; the value it picks is \ for your index automatically, up to 192 MiB; the value it picks is \
shown below.\n\n\ shown below.\n\n\
It matters on an encrypted index, which must decrypt every page the \ It matters on an encrypted index, which must decrypt every page the \
cache misses: a cache smaller than the one shown can make searches \ cache misses: a cache smaller than the one shown can make searches \
about four times slower. An unencrypted index gets a small fixed \ about one and a half times slower. An unencrypted index gets a small fixed \
cache, because a larger one measures no faster. The memory is \ cache to save memory; a larger one makes its searches at most about a \
released after a long idle.", quarter faster. The memory is released after a long idle.",
examples: &[ examples: &[
"0, the default, is right unless your folders are nested unusually deep.", "0, the default, is right unless your folders are nested unusually deep.",
"a fixed value to cap the memory QuickSearch holds, at the cost of slower \ "a fixed value to cap the memory QuickSearch holds, at the cost of slower \

View file

@ -179,8 +179,8 @@ const PAGES: &[Page] = &[
extension if you prefer.", extension if you prefer.",
"It is a quick way to find the same download sitting in three \ "It is a quick way to find the same download sitting in three \
places. It only shows the groups; deleting is up to you.", places. It only shows the groups; deleting is up to you.",
"For speed, files are matched on size and their first few \ "For speed, files are matched on size and their first and last \
kilobytes, so a group is a strong suspicion, not a certainty. \ few kilobytes, so a group is a strong suspicion, not a certainty. \
Right-click a group and choose \"Verify copies are identical\" to \ Right-click a group and choose \"Verify copies are identical\" to \
compare every byte before you delete anything.", compare every byte before you delete anything.",
], ],