quick_search/crates/quicksearch-core/tests/full_index.rs

1826 lines
66 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end phase-1 tests over a real tree and a real database.
use std::path::Path;
use std::time::{Duration, Instant, SystemTime};
use quicksearch_core::config::Config;
use quicksearch_core::file_handling::{count_extract_scope, mark_oversize_pending_na, RootRange};
use quicksearch_core::indexing::{IndexingService, IndexingStatus, RootPhase};
use quicksearch_core::testutil::Scratch;
mod common;
use common::{clear_marker, touch};
fn index_roots_once(roots: &[&Path], db: &Path, config: &Config) {
common::IndexOnce {
db,
roots: roots
.iter()
.map(|r| r.to_string_lossy().into_owned())
.collect(),
config,
fresh_marker: true,
encrypted: false,
}
.run()
}
fn index_once(root: &Path, db: &Path, config: &Config) {
index_roots_once(&[root], db, config)
}
/// Start a run indexing `root` into `db`.
fn start(root: &Path, db: &Path, config: &Config) -> IndexingService {
let service = IndexingService::new();
service
.start_indexing(
vec![root.to_string_lossy().into_owned()],
db.to_string_lossy().into_owned(),
config.clone(),
)
.unwrap();
service
}
/// Poll `service` until `Idle`, calling `each_poll` first every time. `Idle`
/// is the end of the post-run maintenance pass and a resting state, so waiting
/// for it cannot miss it however fast the pass was.
fn run_to_idle(service: &IndexingService, mut each_poll: impl FnMut()) {
let deadline = Instant::now() + Duration::from_secs(180);
loop {
each_poll();
match service.get_status() {
IndexingStatus::Idle => return,
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
_ => {}
}
assert!(Instant::now() < deadline, "the run never reached Idle");
std::thread::sleep(Duration::from_millis(1));
}
}
/// The single integer `sql` returns.
fn count(db: &Path, sql: &str) -> i64 {
rusqlite::Connection::open(db)
.unwrap()
.query_row(sql, [], |r| r.get(0))
.unwrap()
}
/// FTS rows matching `word`.
fn hits(db: &Path, word: &str) -> i64 {
quicksearch_core::testutil::fts_count(&rusqlite::Connection::open(db).unwrap(), word)
}
/// Bytes in `db`'s write-ahead log; 0 when there is none.
fn wal_bytes(db: &Path) -> u64 {
let mut wal = db.as_os_str().to_owned();
wal.push("-wal");
std::fs::metadata(wal).map(|m| m.len()).unwrap_or(0)
}
fn rows(db: &Path) -> Vec<(String, i64, i64)> {
let conn = rusqlite::Connection::open(db).unwrap();
let mut stmt = conn
.prepare("SELECT parent || name, mtime, content_state FROM files ORDER BY parent, name")
.unwrap();
let out = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
out
}
/// The file names of [`rows`], in the same order.
fn names(db: &Path) -> Vec<String> {
rows(db)
.into_iter()
.map(|(p, _, _)| {
Path::new(&p)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect()
}
/// Whether `db` holds the `last_full_index` marker, which only a run that
/// finished writes.
fn stamped(db: &Path) -> bool {
let conn = rusqlite::Connection::open(db).unwrap();
matches!(
quicksearch_core::db::repo::get_last_full_index(&conn),
Ok(Some(_))
)
}
#[test]
fn reindexing_an_unchanged_tree_changes_nothing() {
let root = Scratch::dir("stable");
let (_db_dir, db) = Scratch::db("stable-db");
let config = Config::default();
touch(&root.join("a.txt"), b"alpha");
touch(&root.join("sub/b.txt"), b"bravo");
touch(&root.join("sub/deep/c.txt"), b"charlie");
touch(&root.join("other/d.md"), b"delta");
index_once(&root, &db, &config);
let first = rows(&db);
assert_eq!(first.len(), 4, "all four files indexed");
index_once(&root, &db, &config);
let second = rows(&db);
assert_eq!(
first, second,
"an unchanged tree must re-index to an identical set"
);
}
#[test]
fn deleted_files_are_removed_and_new_ones_added() {
let root = Scratch::dir("churn");
let (_db_dir, db) = Scratch::db("churn-db");
let config = Config::default();
touch(&root.join("keep.txt"), b"keep");
touch(&root.join("remove.txt"), b"remove");
index_once(&root, &db, &config);
assert_eq!(rows(&db).len(), 2);
std::fs::remove_file(root.join("remove.txt")).unwrap();
touch(&root.join("added.txt"), b"added");
index_once(&root, &db, &config);
assert_eq!(
names(&db),
vec!["added.txt", "keep.txt"],
"stale cleanup still works"
);
}
#[test]
fn a_modified_file_is_updated_in_place() {
let root = Scratch::dir("modify");
let (_db_dir, db) = Scratch::db("modify-db");
let config = Config::default();
let target = root.join("doc.txt");
touch(&target, b"first");
index_once(&root, &db, &config);
let before = rows(&db);
assert_eq!(before.len(), 1);
touch(&target, b"second body, clearly different");
let later = SystemTime::now() + Duration::from_secs(5);
filetime_set(&target, later);
index_once(&root, &db, &config);
let after = rows(&db);
assert_eq!(after.len(), 1, "still exactly one row");
assert_ne!(before[0].1, after[0].1, "mtime was refreshed");
assert_eq!(before[0].0, after[0].0, "same path");
assert_eq!(
stored_text(&db, "doc.txt").as_deref(),
Some("second body, clearly different"),
"the stored body is the new one"
);
assert_eq!(hits(&db, "clearly"), 1, "the new body is searchable");
assert_eq!(hits(&db, "first"), 0, "the old body's tokens are gone");
}
fn filetime_set(path: &Path, when: SystemTime) {
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_modified(when).unwrap();
f.sync_all().unwrap();
}
#[test]
fn stopping_mid_run_deletes_nothing() {
let root = Scratch::dir("stop");
let (_db_dir, db) = Scratch::db("stop-db");
let config = Config::default();
for i in 0..1500 {
touch(&root.join(format!("d{}/f{:04}.txt", i % 25, i)), b"body");
}
index_once(&root, &db, &config);
let full = rows(&db);
assert_eq!(full.len(), 1500);
// One file per writer round, so the walk lasts 1500 rounds on any host.
let mut slow = config.clone();
slow.processing.batch_size = 1;
slow.processing.writer_turn_slice_ms = 0;
clear_marker(&db);
// Stop only once a snapshot shows a root still Walking, which proves the
// stop landed mid-run.
let service = start(&root, &db, &slow);
let deadline = Instant::now() + Duration::from_secs(120);
loop {
assert!(Instant::now() < deadline, "the run never became observable");
match service.get_status() {
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
IndexingStatus::Preparing { .. } => {}
IndexingStatus::Running { roots, .. }
if roots.iter().any(|r| r.phase == RootPhase::Walking) =>
{
break;
}
other => panic!(
"the walk ended before a stop could land in it ({:?}); grow the fixture",
other
),
}
std::thread::sleep(Duration::from_micros(100));
}
service.request_stop();
run_to_idle(&service, || {});
assert!(
!stamped(&db),
"the stopped run stamped itself complete: the stop landed after the walk"
);
let after = rows(&db);
assert_eq!(
after.len(),
1500,
"an interrupted run must not delete the rows it never got to"
);
}
#[test]
fn a_stamped_run_has_finished_its_stale_cleanup() {
// A stamp always means stale cleanup finished. The assertion is one-sided,
// so timing cannot fail it spuriously, whether the stop landed inside the
// window or not at all.
let root = Scratch::dir("stop-stamp");
let (_db_dir, db) = Scratch::db("stop-stamp-db");
let mut config = Config::default();
// No extraction, so the run's whole tail is the stale cleanup to interrupt.
config.processing.maximum_text_file_size = 0;
const FILES: usize = 8000;
let file = |i: usize| root.join(format!("d{}/f{:05}.txt", i % 25, i));
for i in 0..FILES {
touch(&file(i), b"body");
}
index_once(&root, &db, &config);
assert_eq!(rows(&db).len(), FILES);
for i in 0..FILES {
std::fs::remove_file(file(i)).unwrap();
}
// The ladder samples the race. The trailing `None` lets the run finish, so
// the loop ends stamped however loaded the host: the suite runs in
// parallel, and under load no stopped run may stamp within 200 ms.
for delay_ms in [2, 5, 10, 20, 35, 60, 100, 200]
.map(Some)
.into_iter()
.chain([None])
{
clear_marker(&db);
assert!(!stamped(&db), "stamp cleared before the run");
// To `Idle` either way: the next rung's run must not start beside
// this one's tail or optimize pass.
let service = start(&root, &db, &config);
if let Some(ms) = delay_ms {
std::thread::sleep(Duration::from_millis(ms));
service.request_stop();
}
run_to_idle(&service, || {});
if stamped(&db) {
assert_eq!(
rows(&db).len(),
0,
"delay {:?}: the run stamped itself complete but left stale rows behind",
delay_ms
);
return;
}
}
// The `None` pass is never stopped, so arriving here unstamped is a defect
// in the stamp itself.
panic!(
"a run allowed to finish did not stamp itself; the fixture never \
exercised the stamp-means-clean invariant"
);
}
#[test]
fn starting_a_run_claims_the_status_before_it_returns() {
let root = Scratch::dir("start-claims");
let (_db_dir, db) = Scratch::db("start-claims-db");
let config = Config::default();
touch(&root.join("a.txt"), b"body");
let service = start(&root, &db, &config);
// No sleep, no poll. `Preparing` is a claim: it holds the index exactly
// as `Running` does.
assert!(
matches!(service.get_status(), IndexingStatus::Preparing { .. }),
"status must be claimed synchronously, got {:?}",
service.get_status()
);
let err = service
.start_indexing(
vec![root.to_string_lossy().into_owned()],
db.to_string_lossy().into_owned(),
config.clone(),
)
.unwrap_err();
assert!(err.contains("already running"), "got: {}", err);
service.request_stop();
run_to_idle(&service, || {});
}
#[test]
fn a_wide_tree_indexes_every_file_exactly_once() {
let root = Scratch::dir("wide");
let (_db_dir, db) = Scratch::db("wide-db");
let config = Config::default();
let count = 900;
for i in 0..count {
touch(&root.join(format!("d{}/f{:04}.txt", i % 13, i)), b"body");
}
index_once(&root, &db, &config);
assert_eq!(rows(&db).len(), count, "every file indexed exactly once");
index_once(&root, &db, &config);
assert_eq!(rows(&db).len(), count, "and the second run is stable");
}
#[test]
fn two_roots_walk_extract_and_clean_independently() {
let root_a = Scratch::dir("multi-a");
let root_b = Scratch::dir("multi-b");
let (_db_dir, db) = Scratch::db("multi-db");
let config = Config::default();
for i in 0..60 {
touch(
&root_a.join(format!("a{:03}.txt", i)),
b"alpha corpus xylophone",
);
}
for i in 0..5 {
touch(
&root_b.join(format!("b{:03}.txt", i)),
b"bravo corpus quagmire",
);
}
index_roots_once(&[&root_a, &root_b], &db, &config);
assert_eq!(
count(&db, "SELECT COUNT(*) FROM files"),
65,
"both roots fully walked"
);
assert_eq!(
count(&db, "SELECT COUNT(*) FROM files WHERE content_state = 0"),
0,
"per-root extraction drained both roots"
);
for word in ["xylophone", "quagmire"] {
assert!(
hits(&db, word) > 0,
"content from both roots must be indexed ({})",
word
);
}
std::fs::remove_file(root_b.join("b000.txt")).unwrap();
index_roots_once(&[&root_a, &root_b], &db, &config);
assert_eq!(
count(&db, "SELECT COUNT(*) FROM files"),
64,
"stale row swept across roots"
);
}
/// The search cache is sized from the per-root counts a completed run stores
/// (`tests/search_cache.rs` covers that half), so after every run they must
/// add up to the rows it left.
#[test]
fn a_completed_run_stores_its_exact_file_count() {
let root_a = Scratch::dir("stored-count-a");
let root_b = Scratch::dir("stored-count-b");
let (_db_dir, db) = Scratch::db("stored-count-db");
let config = Config::default();
for i in 0..40 {
touch(&root_a.join(format!("d{}/a{:03}.txt", i % 4, i)), b"alpha");
}
for i in 0..7 {
touch(&root_b.join(format!("b{:03}.txt", i)), b"bravo");
}
let stored = |db: &Path| {
quicksearch_core::db::repo::stored_file_count(&rusqlite::Connection::open(db).unwrap())
};
index_roots_once(&[&root_a, &root_b], &db, &config);
assert_eq!(count(&db, "SELECT COUNT(*) FROM files"), 47);
assert_eq!(stored(&db), 47, "the stored counts must add up to the rows");
// Ten files go with `d0`, one arrives.
std::fs::remove_dir_all(root_a.join("d0")).unwrap();
touch(&root_b.join("late.txt"), b"charlie");
index_roots_once(&[&root_a, &root_b], &db, &config);
assert_eq!(count(&db, "SELECT COUNT(*) FROM files"), 38);
assert_eq!(stored(&db), 38, "the next run's counts replace the last");
}
// ---------------------------------------------------------------------------
// Reconciliation without a global path set: classification and stale
// detection are per-directory, so these cover the cases a single directory
// read cannot see.
// ---------------------------------------------------------------------------
#[test]
fn a_deleted_directory_takes_its_whole_subtree_out_of_the_index() {
let root = Scratch::dir("gone-dir");
let (_db_dir, db) = Scratch::db("gone-dir-db");
let config = Config::default();
touch(&root.join("keep.txt"), b"stays");
touch(&root.join("doomed/a.txt"), b"goes");
touch(&root.join("doomed/b.txt"), b"goes");
touch(&root.join("doomed/deeper/c.txt"), b"goes too");
index_once(&root, &db, &config);
assert_eq!(rows(&db).len(), 4, "all four indexed");
std::fs::remove_dir_all(root.join("doomed")).unwrap();
index_once(&root, &db, &config);
assert_eq!(names(&db), vec!["keep.txt"], "the whole subtree is swept");
}
#[test]
fn overlapping_roots_index_each_file_exactly_once() {
let outer = Scratch::dir("overlap-outer");
let (_db_dir, db) = Scratch::db("overlap-db");
let config = Config::default();
let inner = outer.join("inner");
touch(&outer.join("top.txt"), b"in the outer root only");
touch(&inner.join("shared.txt"), b"reachable from both roots");
touch(&inner.join("also.txt"), b"likewise");
index_roots_once(&[&outer, &inner], &db, &config);
let all = rows(&db);
assert_eq!(all.len(), 3, "three files, however many roots reach them");
let shared: Vec<&(String, i64, i64)> = all
.iter()
.filter(|(p, _, _)| p.ends_with("shared.txt"))
.collect();
assert_eq!(
shared.len(),
1,
"the doubly-reachable file has exactly one row"
);
index_roots_once(&[&outer, &inner], &db, &config);
assert_eq!(rows(&db), all, "a second overlapping run changes nothing");
}
/// Unix only: a Windows `icacls` deny ACE does not bind the owning process
/// reliably enough to test against.
#[test]
#[cfg(unix)]
fn a_directory_that_becomes_unreadable_deletes_nothing() {
let root = Scratch::dir("locked-later");
let (_db_dir, db) = Scratch::db("locked-later-db");
let config = Config::default();
touch(&root.join("open.txt"), b"always readable");
let vault = root.join("vault");
touch(&vault.join("secret.txt"), b"readable for now");
touch(&vault.join("deeper/also.txt"), b"and this one");
index_once(&root, &db, &config);
let before = rows(&db);
assert_eq!(before.len(), 3, "all three indexed while readable");
// Fails, rather than skips, under rights that ignore the mode (root),
// which would test a readable directory; CI drops them.
quicksearch_core::platform::deny_read(&vault).unwrap();
index_once(&root, &db, &config);
let after = rows(&db);
quicksearch_core::platform::restore_read(&vault).ok();
assert_eq!(after, before, "an unreadable directory is not an empty one");
index_once(&root, &db, &config);
assert_eq!(rows(&db), before, "readable again, nothing changed");
}
/// The root's recorded file count: one root per test, so the only one.
fn walk_count(db: &Path) -> i64 {
count(
db,
"SELECT CAST(value AS INTEGER) FROM schema_info WHERE key LIKE 'walk_count:%'",
)
}
/// An unmounted static mountpoint (fstab `nofail`, a backup disk not plugged
/// in) resolves and lists as empty, the same as a root whose files were all
/// deleted.
#[test]
fn a_root_with_nothing_mounted_keeps_its_entries() {
let base = Scratch::canonical("unmounted");
let root = base.join("mnt");
let (_db_dir, db) = Scratch::db("unmounted-db");
let config = Config::default();
touch(&root.join("a.txt"), b"alpha");
touch(&root.join("sub/b.txt"), b"bravo");
index_once(&root, &db, &config);
let before = rows(&db);
assert_eq!(before.len(), 2);
assert_eq!(walk_count(&db), 2);
let disk = base.join("disk");
std::fs::rename(&root, &disk).unwrap();
std::fs::create_dir(&root).unwrap();
index_once(&root, &db, &config);
assert_eq!(
rows(&db),
before,
"an empty mountpoint is not an emptied root"
);
assert_eq!(walk_count(&db), 2, "nor a root of no files");
// Forgotten, so only the next walk can put it back.
rusqlite::Connection::open(&db)
.unwrap()
.execute("DELETE FROM schema_info WHERE key LIKE 'walk_count:%'", [])
.unwrap();
std::fs::remove_dir(&root).unwrap();
std::fs::rename(&disk, &root).unwrap();
index_once(&root, &db, &config);
assert_eq!(rows(&db), before, "mounted again, nothing changed");
assert_eq!(walk_count(&db), 2, "and a clean walk recorded its count");
}
// ---------------------------------------------------------------------------
// Inline extraction: the walk finishes files whose head is the whole file.
// `hash_length` decides how much the walk reads, so 0 leaves nothing to
// inline and serves as the control the optimised path must match.
// ---------------------------------------------------------------------------
type ContentRow = (String, i64, Option<String>, Option<i64>);
fn content_rows(db: &Path) -> Vec<ContentRow> {
let conn = rusqlite::Connection::open(db).unwrap();
let mut stmt = conn
.prepare(
"SELECT f.parent || f.name, f.content_state, ff.reason, LENGTH(d.text_zstd)
FROM files f
LEFT JOIN documents_text d ON d.file_id = f.id
LEFT JOIN failed_files ff ON ff.file_id = f.id
ORDER BY f.parent, f.name",
)
.unwrap();
let out = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
out
}
fn stored_text(db: &Path, suffix: &str) -> Option<String> {
let conn = rusqlite::Connection::open(db).unwrap();
let blob: Option<Vec<u8>> = conn
.query_row(
"SELECT d.text_zstd FROM documents_text d
JOIN files f ON f.id = d.file_id
WHERE f.parent || f.name LIKE '%' || ?1",
[suffix],
|r| r.get(0),
)
.ok();
blob.map(|b| String::from_utf8(zstd::decode_all(&b[..]).unwrap()).unwrap())
}
fn seed_mixed_tree(root: &Path) {
let big = "lorem ipsum dolor sit amet ".repeat(600); // ~16 KiB, past any head
touch(
&root.join("small.txt"),
b"a small plaintext body with xylophone in it",
);
touch(&root.join("large.txt"), big.as_bytes());
touch(&root.join("empty.txt"), b"");
// The NUL fails the binary guard, and the FF FE pair is not at offset 0,
// so it is no BOM.
touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe, 0x00, 0x41]);
// NUL soup: no extension table, magic, or text sniff claims it.
touch(
&root.join("blob.bin"),
&[0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff],
);
touch(
&root.join("nested/deep/note.md"),
b"# heading\n\nquagmire body text\n",
);
}
#[test]
fn inline_extraction_produces_an_identical_index_to_the_two_pass_path() {
let root = Scratch::dir("inline-equiv");
let db_dir = Scratch::dir("inline-equiv-db");
seed_mixed_tree(&root);
// Control: hash_length 0 => empty head => nothing can be inlined.
let mut control = Config::default();
control.processing.hash_length = 0;
let db_control = db_dir.join("control.sqlite");
index_once(&root, &db_control, &control);
// Optimised: the default head covers every small file in the tree.
let optimised = Config::default();
let db_opt = db_dir.join("optimised.sqlite");
index_once(&root, &db_opt, &optimised);
assert_eq!(
content_rows(&db_control),
content_rows(&db_opt),
"inlining during the walk must not change a single indexed byte"
);
// And the bodies themselves round-trip identically, not just their lengths.
for f in ["small.txt", "large.txt", "note.md"] {
assert_eq!(
stored_text(&db_control, f),
stored_text(&db_opt, f),
"stored body differs for {}",
f
);
}
}
#[test]
fn the_head_boundary_decides_inlining_without_changing_the_result() {
let root = Scratch::dir("inline-boundary");
let (_db_dir, db) = Scratch::db("inline-boundary-db");
let mut config = Config::default();
config.processing.hash_length = 64;
let at = "x".repeat(64);
let past = "y".repeat(65);
touch(&root.join("at.txt"), at.as_bytes());
touch(&root.join("past.txt"), past.as_bytes());
index_once(&root, &db, &config);
assert_eq!(
count(&db, "SELECT COUNT(*) FROM files WHERE content_state != 1"),
0,
"both sides of the boundary end up extracted"
);
assert_eq!(stored_text(&db, "at.txt").as_deref(), Some(at.as_str()));
assert_eq!(stored_text(&db, "past.txt").as_deref(), Some(past.as_str()));
}
/// The content state and failure reason of the one file ending in `suffix`.
fn failure(db: &Path, suffix: &str) -> (i64, Option<String>) {
rusqlite::Connection::open(db)
.unwrap()
.query_row(
"SELECT f.content_state, ff.reason FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.parent || f.name LIKE '%' || ?1",
[suffix],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap()
}
#[test]
fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() {
let root = Scratch::dir("inline-badutf8");
let (_db_dir, db) = Scratch::db("inline-badutf8-db");
// The NUL keeps this undecodable: without it these bytes decode as
// windows-1252 and the test asserts nothing.
touch(&root.join("bad.txt"), &[0x68, 0x00, 0x69, 0xff]);
index_once(&root, &db, &Config::default());
let (state, msg) = failure(&db, "bad.txt");
assert_eq!(state, 2, "undecodable content is FAILED, not DONE or NA");
assert!(
msg.unwrap_or_default().contains("bad.txt"),
"the failure names the file"
);
}
#[test]
fn an_unreadable_legacy_office_file_fails_with_a_reason() {
let root = Scratch::dir("legacy-doc");
let (_db_dir, db) = Scratch::db("legacy-doc-db");
touch(
&root.join("broken.doc"),
b"D0CF11E0 this is not really a compound file",
);
index_once(&root, &db, &Config::default());
let (state, msg) = failure(&db, "broken.doc");
assert_eq!(
state, 2,
"an unreadable .doc is FAILED, not DONE-with-no-text"
);
let msg = msg.unwrap_or_default();
assert!(msg.contains("broken.doc"), "names the file: {msg}");
assert!(msg.contains("compound file"), "says what went wrong: {msg}");
}
/// The fix keeps size and mtime identical, so the walk sees an unchanged file
/// and only the run-start retry of failed files can rescue it.
#[test]
fn a_failed_file_is_retried_on_the_next_run() {
let root = Scratch::dir("retry-failed");
let (_db_dir, db) = Scratch::db("retry-failed-db");
let flaky = root.join("flaky.txt");
// The NUL keeps this undecodable (see the inline-badutf8 test).
touch(&flaky, &[0x68, 0x00, 0x69, 0xff]);
index_once(&root, &db, &Config::default());
let probe = |db: &Path| -> (i64, i64) {
let conn = rusqlite::Connection::open(db).unwrap();
conn.query_row(
"SELECT f.content_state,
(SELECT COUNT(*) FROM failed_files) FROM files f \
WHERE f.name = 'flaky.txt'",
[],
|r| Ok((r.get(0).unwrap(), r.get(1).unwrap())),
)
.unwrap()
};
assert_eq!(probe(&db), (2, 1), "the first run records the failure");
let mtime = std::fs::metadata(&flaky).unwrap().modified().unwrap();
touch(&flaky, b"hiya");
std::fs::File::options()
.write(true)
.open(&flaky)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(mtime))
.unwrap();
index_once(&root, &db, &Config::default());
assert_eq!(
probe(&db),
(1, 0),
"the retry re-extracted the fixed file and cleared the record"
);
assert_eq!(stored_text(&db, "flaky.txt").as_deref(), Some("hiya"));
}
#[test]
fn extensionless_text_files_are_indexed() {
let root = Scratch::dir("extless");
let (_db_dir, db) = Scratch::db("extless-db");
touch(
&root.join("README"),
b"QuickSearch indexes zanzibar contents.\n",
);
touch(&root.join("Makefile"), b"all:\n\tcargo build --release\n");
touch(&root.join("go.sum"), b"example.com/x v1.0.0 h1:abcdef=\n");
touch(&root.join("blob"), &[0x00, 0x01, 0xfe, 0xff]);
index_once(&root, &db, &Config::default());
let conn = rusqlite::Connection::open(&db).unwrap();
let state_of = |name: &str| -> i64 {
conn.query_row(
"SELECT content_state FROM files WHERE parent || name LIKE '%' || ?1",
[name],
|r| r.get(0),
)
.unwrap()
};
for name in ["README", "Makefile", "go.sum"] {
assert_eq!(state_of(name), 1, "{} should be content-indexed", name);
}
assert_eq!(state_of("blob"), 3, "binary blob stays not-applicable");
drop(conn);
assert_eq!(
stored_text(&db, "README").as_deref(),
Some("QuickSearch indexes zanzibar contents.\n"),
"the stored body round-trips"
);
}
/// No name to go on: the sniff types it from its bytes (`infer` reads the
/// first member's name), and that MIME picks the parser.
#[test]
fn an_extensionless_docx_is_indexed_with_its_text() {
use std::io::Write;
let root = Scratch::dir("extless-docx");
let (_db_dir, db) = Scratch::db("extless-docx-db");
let mut docx = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
docx.start_file("word/document.xml", zip::write::FileOptions::default())
.unwrap();
docx.write_all(
b"<w:document><w:body><w:p><w:r><w:t>Minutes of the marmot council</w:t></w:r></w:p>\
</w:body></w:document>",
)
.unwrap();
touch(&root.join("minutes"), &docx.finish().unwrap().into_inner());
index_once(&root, &db, &Config::default());
assert_eq!(
stored_text(&db, "minutes").as_deref(),
Some("Minutes of the marmot council\n")
);
}
/// `stored_text` decodes the zstd sidecar with `String::from_utf8`, so a
/// `Some` result *is* the storage-is-UTF-8 assertion.
#[test]
fn utf16_files_are_stored_as_utf8() {
let root = Scratch::dir("charset");
let (_db_dir, db) = Scratch::db("charset-db");
let reg_src =
"Windows Registry Editor Version 5.00\r\n\r\n[HKEY_CURRENT_USER\\Software\\Xylograph]\r\n";
let mut reg_body = vec![0xFF, 0xFE];
reg_body.extend(reg_src.encode_utf16().flat_map(|u| u.to_le_bytes()));
touch(&root.join("export.reg"), &reg_body);
let mut extless = vec![0xFF, 0xFE];
extless.extend(
"utf16 notes about quokkas"
.encode_utf16()
.flat_map(|u| u.to_le_bytes()),
);
touch(&root.join("NOTES16"), &extless);
touch(
&root.join("legacy.txt"),
b"un caf\xe9 tr\xe8s agr\xe9able pr\xe8s du mus\xe9e",
);
index_once(&root, &db, &Config::default());
assert_eq!(stored_text(&db, "export.reg").as_deref(), Some(reg_src));
assert_eq!(
stored_text(&db, "NOTES16").as_deref(),
Some("utf16 notes about quokkas")
);
assert_eq!(
stored_text(&db, "legacy.txt").as_deref(),
Some("un café très agréable près du musée")
);
}
/// Covers both extraction paths: a small file the walk inlines, and one past
/// `hash_length` that the content pass opens.
#[test]
fn rtf_files_are_extracted() {
let root = Scratch::dir("rtf");
let (_db_dir, db) = Scratch::db("rtf-db");
touch(
&root.join("small.rtf"),
br"{\rtf1\ansi Meeting notes about the pangolin budget.}",
);
let big_body = format!(
r"{{\rtf1\ansi {}}}",
r"paragraphs about the pangolin budget \par ".repeat(400)
);
assert!(big_body.len() > 8192, "must exceed the default head");
touch(&root.join("big.rtf"), big_body.as_bytes());
index_once(&root, &db, &Config::default());
for name in ["small.rtf", "big.rtf"] {
let text = stored_text(&db, name).unwrap_or_else(|| panic!("{} has no stored text", name));
assert!(
text.contains("pangolin budget"),
"{}: {:?}",
name,
&text[..text.len().min(80)]
);
assert!(!text.contains(r"\rtf"), "{} stored control words", name);
}
}
#[test]
fn the_extraction_denominator_counts_only_files_that_need_text() {
let root = Scratch::dir("denominator");
let (_db_dir, db) = Scratch::db("denominator-db");
// `big.txt` exceeds `hash_length`, so it is the only row the content pass
// opens; the NUL-bearing seven are claimed by nothing.
for name in ["a.txt", "b.json"] {
touch(&root.join(name), b"body bytes with no magic");
}
touch(&root.join("big.txt"), &vec![b'z'; 32 * 1024]);
for name in ["d.mp4", "e.zip", "f.bin", "g.exe", "h.iso", "i.so", "j"] {
touch(&root.join(name), b"\x00\x01body bytes\x00");
}
let config = Config::default();
index_once(&root, &db, &config);
let in_state = |state: i64| {
count(
&db,
&format!("SELECT COUNT(*) FROM files WHERE content_state = {}", state),
)
};
assert_eq!(in_state(0), 0, "a finished run leaves nothing pending");
assert_eq!(in_state(1), 3, "the claimed files have text");
assert_eq!(
in_state(3),
7,
"the rest are NA, and were NA from the walk on"
);
// The calls the production path makes to fill
// `RootProgress::extract_total`, in production order, against the finished
// index: a ten-file tree finishes between two polls, so the live status
// cannot be sampled.
let conn = quicksearch_core::db::open_existing(db.to_str().unwrap(), true).unwrap();
let range = RootRange::for_root(root.to_str().unwrap());
mark_oversize_pending_na(&conn, &range, &config).unwrap();
let scope = count_extract_scope(&conn, &range, &config).unwrap();
assert_eq!(
(scope.pending, scope.already_done),
(0, 3),
"extract_total is the searchable set, not the file count"
);
drop(conn);
}
#[test]
fn an_empty_file_is_done_with_no_snippet_sidecar() {
let root = Scratch::dir("inline-empty");
let (_db_dir, db) = Scratch::db("inline-empty-db");
touch(&root.join("empty.txt"), b"");
index_once(&root, &db, &Config::default());
let conn = rusqlite::Connection::open(&db).unwrap();
let (state, sidecars): (i64, i64) = conn
.query_row(
"SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id)
FROM files f WHERE f.parent || f.name LIKE '%empty.txt'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(state, 1, "an empty file is extracted, not failed");
assert_eq!(sidecars, 0, "no zstd frame for an empty body");
}
#[test]
fn the_content_extension_filter_still_excludes_small_text_files() {
let root = Scratch::dir("inline-filter");
let (_db_dir, db) = Scratch::db("inline-filter-db");
let mut config = Config::default();
config.indexing.content_extensions = vec!["md".into()];
touch(&root.join("kept.md"), b"kept quagmire body");
touch(&root.join("skipped.txt"), b"skipped xylophone body");
index_once(&root, &db, &config);
let states: Vec<i64> = rows(&db).into_iter().map(|(_, _, state)| state).collect();
assert_eq!(
states,
[1, 3],
"kept.md is extracted; skipped.txt, a filtered extension, is NA, never inlined"
);
assert_eq!(
stored_text(&db, "skipped.txt"),
None,
"no body stored for a filtered file"
);
}
/// Both layouts index the same files the same way; only the FTS table
/// differs, and every file with text keeps its body for verification.
#[test]
fn either_index_layout_stores_and_finds_the_same_files() {
for compact in [true, false] {
let root = Scratch::dir("layout");
let (_db_dir, db) = Scratch::db("layout-db");
let mut config = Config::default();
config.processing.compact_text_index = compact;
touch(&root.join("small.txt"), b"searchable xylophone body");
touch(&root.join("other.txt"), b"nothing to see");
index_once(&root, &db, &config);
assert_eq!(
count(&db, "SELECT COUNT(*) FROM documents_text"),
2,
"every body is stored (compact = {compact})"
);
assert_eq!(hits(&db, "xylophone"), 1, "compact = {compact}");
let detail_none = count(
&db,
"SELECT COUNT(*) FROM sqlite_master \
WHERE name = 'searchabletext' AND sql LIKE '%detail=none%'",
);
assert_eq!(detail_none == 1, compact);
}
}
// ---------------------------------------------------------------------------
// How the stall tests measure interleaving; `observe_overlap` collects the
// counters.
//
// A stall is counted in work, not milliseconds: while the heavy root's
// content pass runs, how many files the light root's walk was drained of,
// against how many heavy rows landed. The same writer loop advances both
// counters (`service_walking` / `service_extracting` in
// `indexing/pipeline.rs`), each root's turn bounded by one slice, so their
// ratio is the interleaving, and a slow host freezes both together.
//
// `writer_turn_slice_ms = 0` makes the verdict arithmetic rather than a
// measurement of the host: with no time in a turn, a writer round is one
// bounded piece of work per root (`service_walking` runs one `batch_size`
// batch, `store_extracted` consumes one row). The round is the unit, the
// interleave floor is `BATCH : 1` on any host, and load can only raise it.
// The ratio is also uniform across the pass, so the observed window may be a
// partial sample of it.
//
// The bound: a serialised writer, which reads the heavy batch itself and
// drains nobody meanwhile, manages one batch of each per round, so it obeys
// `light < heavy + BATCH`. Each test demands
// `light_drained ≥ MIN_INTERLEAVE × (heavy_stored + BATCH)`: three times a
// bound the serialised design cannot reach, against a floor of 16:1.
//
// `MIN_ROWS_SAMPLED`: a window of `n` rows drains `BATCH × n` light files at
// the floor and must beat `MIN_INTERLEAVE × (n + BATCH)`, so under four rows
// the constant `+ BATCH` term decides the comparison instead of the design.
// ---------------------------------------------------------------------------
/// What one watch of a heavy/light overlap saw; see [`observe_overlap`].
struct Overlap {
/// Light files drained and heavy rows stored, across the window in which
/// the heavy root extracted while the light root walked.
light_drained: usize,
heavy_stored: usize,
/// The heavy root's `extract_total` and pool size, for the fixture guards.
heavy_pending: usize,
heavy_pool: usize,
/// Publications in which the heavy root's row count moved. Reported, never
/// asserted: it is `min(writer rounds, watcher polls)`, there to make a
/// surprising run legible.
heavy_steps: usize,
/// Longest this watcher went between polls: distinguishes a writer that
/// gulped the pass in one turn from a watcher descheduled past it.
worst_gap: Duration,
}
/// Watch a two-root run until the heavy root has finished extracting, report
/// how the two counters moved while both were in flight, then stop the run
/// and wait for it to end. Removing the fixture is the caller's job.
///
/// The window opens on the first snapshot holding both roots in flight and
/// closes when the heavy root leaves `Extracting`, not when the light root
/// finishes walking, which is a race between two rates with no fixed ratio
/// across hosts. A light walk that ends early just stops contributing,
/// understating the interleaving, never overstating it.
/// Panics if the window never opened.
fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str) -> Overlap {
let mut opened: Option<(usize, usize)> = None; // (light.walked, heavy.extracted)
let mut last = (0usize, 0usize);
let mut heavy_pending = 0usize;
let mut heavy_pool = 0usize;
let mut heavy_steps = 0usize;
let mut stepped_at = 0usize;
let mut worst_gap = Duration::ZERO;
let mut polled_at = Instant::now();
// Published (heavy, light) phase pairs, kept only for the failure diagnosis.
let mut phases: Vec<(RootPhase, RootPhase)> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline {
let mut in_window = false;
worst_gap = worst_gap.max(polled_at.elapsed());
polled_at = Instant::now();
match service.get_status() {
IndexingStatus::Running { roots, .. } => {
let heavy_p = roots.iter().find(|r| r.root.contains(heavy_tag));
let light_p = roots.iter().find(|r| r.root.contains(light_tag));
if let (Some(h), Some(l)) = (heavy_p, light_p) {
if phases.last() != Some(&(h.phase, l.phase)) {
phases.push((h.phase, l.phase));
}
// Opening takes both roots in flight; staying open takes
// only the heavy root's pass.
in_window = if opened.is_none() {
h.phase == RootPhase::Extracting && l.phase == RootPhase::Walking
} else {
h.phase == RootPhase::Extracting
};
if in_window {
last = (l.walked, h.extracted);
if opened.is_none() {
opened = Some(last);
stepped_at = h.extracted;
}
// A fresh publication, not a fresh poll.
if h.extracted > stepped_at {
stepped_at = h.extracted;
heavy_steps += 1;
}
if let Some(total) = h.extract_total {
heavy_pending = total;
}
heavy_pool = h.total_workers;
}
}
}
// Claimed but not yet walking; nothing to sample yet.
IndexingStatus::Preparing { .. } => {}
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
_ => break,
}
// Both phases are monotone, so a closed window will not reopen.
if opened.is_some() && !in_window {
break;
}
// Poll finer than a writer round, or this loop sets the window's edges.
std::thread::sleep(Duration::from_micros(500));
}
service.request_stop();
run_to_idle(service, || {});
let Some((light_open, heavy_open)) = opened else {
panic!(
"never observed the heavy root extracting while the light root walked. \
Published (heavy, light) phases: {:?}. An empty list means neither \
root matched the tags {:?}/{:?}; a list with no heavy Extracting in \
it means the heavy root's content pass began and ended between two \
status publications, so lower `writer_turn_slice_ms` until a writer \
round is shorter than that pass.",
phases, heavy_tag, light_tag
);
};
Overlap {
light_drained: last.0 - light_open,
heavy_stored: last.1 - heavy_open,
heavy_pending,
heavy_pool,
heavy_steps,
worst_gap,
}
}
/// The writer's batch in the stall tests: the bound is arithmetic in it, and a
/// default 500-file round is a coarse enough publish interval to look like a
/// stall on its own.
const BATCH: usize = 16;
/// A wide tree of tiny files, inlined by their walk workers, so the light root
/// has no extraction phase of its own to confuse the window with, and wide
/// enough that it is still walking when the heavy pass starts.
const LIGHT_FILES: usize = 16_000;
/// Light files drained per (heavy row + batch); derived above `Overlap`.
const MIN_INTERLEAVE: usize = 3;
/// Fewest heavy rows a window needs; derived above `Overlap`.
const MIN_ROWS_SAMPLED: usize = 4;
/// Index `heavy_files` copies of `body`, spread over `heavy_dirs` directories
/// and extracted on `workers` threads, beside [`LIGHT_FILES`] one-byte files,
/// and watch the overlap under `config` plus the writer settings derived above
/// `Overlap`. Asserts the fixture guards, each of which silently costs a
/// factor of the margin if it stops holding.
///
/// Removes the fixture before asserting, unlike the rest of this file: it is
/// generated and identical every run, and leaving tens of MB in a RAM-backed
/// /tmp behind a failure is itself a reason to fail.
#[allow(clippy::too_many_arguments)]
fn watch_heavy_beside_light(
tag: &str,
heavy_files: usize,
heavy_dirs: usize,
body: &[u8],
ext: &str,
workers: usize,
mut config: Config,
) -> Overlap {
let (heavy_tag, light_tag) = (format!("{}-heavy", tag), format!("{}-light", tag));
let heavy = Scratch::dir(&heavy_tag);
for i in 0..heavy_files {
touch(
&heavy.join(format!("d{}/big{:04}.{}", i % heavy_dirs, i, ext)),
body,
);
}
let light = Scratch::dir(&light_tag);
for i in 0..LIGHT_FILES {
touch(&light.join(format!("d{}/f{:05}.txt", i % 60, i)), b"x");
}
let (db_dir, db) = Scratch::db(&format!("{}-db", tag));
let roots = vec![
heavy.to_string_lossy().into_owned(),
light.to_string_lossy().into_owned(),
];
config.processing.batch_size = BATCH;
config.processing.writer_turn_slice_ms = 0;
// `root_workers` is keyed by the `indexing_paths` spelling; both sides
// canonicalize before matching.
config.paths.indexing_paths = roots.clone();
config
.indexing
.root_workers
.insert(roots[0].clone(), workers);
// The default WAL cap is far above anything these runs write; that stops
// being true if a fixture grows by an order of magnitude.
let service = IndexingService::new();
service
.start_indexing(roots, db.to_string_lossy().into_owned(), config)
.unwrap();
let seen = observe_overlap(&service, &heavy_tag, &light_tag);
drop(service);
drop((heavy, light, db_dir));
assert_eq!(
seen.heavy_pool, workers,
"the heavy root must extract on the {} workers root_workers asked for",
workers
);
assert_eq!(
seen.heavy_pending, heavy_files,
"every heavy file must reach the content pass; one inlined by its walk \
worker never produces an extraction phase to overlap with"
);
assert!(
seen.heavy_stored >= MIN_ROWS_SAMPLED,
"only {} of {} heavy rows landed inside the observed window, fewer than \
the {} a verdict needs (worst watcher gap {:?}, rows seen over {} \
rounds) — a gap near the pass's own length means this watcher was \
descheduled past it, not that the writer gulped it",
seen.heavy_stored,
heavy_files,
MIN_ROWS_SAMPLED,
seen.worst_gap,
seen.heavy_steps
);
seen
}
/// A slow root does not stall the others: the single writer thread keeps
/// draining the light root's walk while the heavy root extracts.
///
/// The assertion is about stalls, not throughput: writing is serial by
/// construction (one SQLite connection), so a wall-clock comparison would
/// mostly measure the machine. The measurement and bound are derived above
/// [`Overlap`].
#[test]
fn a_heavy_root_does_not_stall_a_light_one() {
// Few files, each big enough that reading one is real work, with a small
// `maximum_text_size` so the cost lands in extraction: the bound grows
// with the row count, while what the light root drains does not. Sized
// for the guards: enough that the rows arrive over separate writer
// rounds, cheap enough that a loaded runner still finishes in seconds.
// RTF, because its extractor reads the whole file and scans every byte up
// to `maximum_text_size` of text, where plaintext stops reading at twice
// that: the filler sits in a skipped destination, so it is all scanned
// and none of it is text.
const HEAVY_FILES: usize = 12;
// ~2 MiB each, 24 MB total.
let body = format!(
"{{\\rtf1\\ansi {{\\*\\qsfill {}}}sphinx of black quartz judge my vow}}",
"sphinx of black quartz judge my vow ".repeat(58_000)
);
let mut config = Config::default();
config.processing.maximum_text_size = 1024;
// Above the heavy files, or `mark_oversize_pending_na` writes them off as
// N/A before the pass starts and there is no extraction phase at all.
config.processing.maximum_text_file_size = 4 * 1024 * 1024;
// One extraction thread: with the default four the pass is four times
// shorter, and so is the margin.
let seen = watch_heavy_beside_light("stall", HEAVY_FILES, 4, body.as_bytes(), "rtf", 1, config);
eprintln!(
"light files drained while the heavy root extracted: {} against {} heavy \
rows (batch {}) landing over {} rounds, worst watcher gap {:?} — \
{}x the {}x required; the \
serialised design cannot exceed 1x",
seen.light_drained,
seen.heavy_stored,
BATCH,
seen.heavy_steps,
seen.worst_gap,
seen.light_drained / (seen.heavy_stored + BATCH),
MIN_INTERLEAVE
);
assert!(
seen.light_drained >= MIN_INTERLEAVE * (seen.heavy_stored + BATCH),
"the light root was drained of only {} files while the heavy root landed \
{} rows; one batch of each per round is all a writer that extracts \
inline can manage, so anything near {} means the extraction is back on \
the writer thread",
seen.light_drained,
seen.heavy_stored,
seen.heavy_stored + BATCH
);
}
/// [`a_heavy_root_does_not_stall_a_light_one`] for the cost that test keeps
/// small: the writer's own tokenising. Each heavy row carries the default
/// 256 KiB of text, and its FTS5 trigram insert runs on the writer thread
/// inside the transaction, so only the turn's slice bounds it. An extraction
/// turn that writes everything it finds brings the ratio below one.
#[test]
fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() {
// Over the walk's inline threshold, with the full 256 KiB of stored text,
// so the tokenising is what is measured.
const HEAVY_FILES: usize = 32;
let body = "sphinx of black quartz judge my vow ".repeat(9_000);
// Four readers, so the ready channel is full when the writer's turn comes.
let seen = watch_heavy_beside_light(
"stall-writer",
HEAVY_FILES,
8,
body.as_bytes(),
"txt",
4,
Config::default(),
);
eprintln!(
"light files drained while the heavy root tokenised: {} against {} heavy \
rows (batch {}) landing over {} rounds, worst watcher gap {:?} — \
{}x the {}x required",
seen.light_drained,
seen.heavy_stored,
BATCH,
seen.heavy_steps,
seen.worst_gap,
seen.light_drained / (seen.heavy_stored + BATCH),
MIN_INTERLEAVE
);
assert!(
seen.light_drained >= MIN_INTERLEAVE * (seen.heavy_stored + BATCH),
"the light root was drained of only {} files while the heavy root landed \
{} rows; an extraction turn is writing to the end of its batch again \
instead of yielding at its slice",
seen.light_drained,
seen.heavy_stored
);
}
/// A text-heavy tree: the FTS index fills the log and dominates the database,
/// so a log measured against the database size means something.
fn seed_text_tree(tag: &str) -> Scratch {
let root = Scratch::dir(tag);
let body: Vec<u8> = "sphinx of black quartz judge my vow "
.repeat(200)
.into_bytes();
for i in 0..4000 {
touch(&root.join(format!("d{}/f{:05}.txt", i % 40, i)), &body);
}
root
}
/// The write-ahead log does not grow for the length of a run. Autocheckpoint
/// can reset the log only at an instant no reader holds a read mark, and a run
/// keeps a reader per root querying continuously (the prompting case: a
/// 12.5 GiB index carrying a 21.6 GiB log).
///
/// The assertion is about the peak while running: `stop_indexing` and the
/// post-run maintenance both truncate the log on the way out, so a reading
/// taken afterwards proves nothing.
#[test]
fn the_wal_stays_bounded_during_a_run() {
let root = seed_text_tree("wal-bound");
let (_db_dir, db) = Scratch::db("wal-bound-db");
let mut config = Config::default();
// The floor `MINIMUM_WAL_SIZE` clamps to, so the cap is exercised many
// times rather than once at the end.
config.processing.maximum_wal_size = 16 * 1024 * 1024;
let service = start(&root, &db, &config);
let mut peak = 0u64;
let mut checkpointed = false;
let mut last = 0u64;
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline {
let len = wal_bytes(&db);
peak = peak.max(len);
// Vacuity guard: without a mid-run checkpoint the bound could be met
// by the fixture being too small.
if len + 1024 * 1024 < last {
checkpointed = true;
}
last = len;
match service.get_status() {
IndexingStatus::Running { .. } | IndexingStatus::Preparing { .. } => {}
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
_ => break,
}
std::thread::sleep(Duration::from_millis(2));
}
run_to_idle(&service, || {});
let after = wal_bytes(&db);
drop(service);
eprintln!("peak WAL during the run: {} bytes", peak);
assert!(
checkpointed,
"the log never shrank mid-run; the fixture is not exercising the cap"
);
// Generously above the cap: the check runs between rounds, so a round's
// commits can land on top, and a checkpoint that loses its lock race
// defers to the next cap of growth.
assert!(
peak < 96 * 1024 * 1024,
"the log peaked at {} bytes against a 16 MiB cap",
peak
);
assert_eq!(after, 0, "the optimize pass leaves an empty log behind");
}
/// A stopped run still runs the optimize pass: a run cut short is when the log
/// is largest, and nothing else lands it.
///
/// Asserted through `repo::optimize_count`, a latch, because the transient
/// `Optimizing` status is gone too fast to poll for.
#[test]
fn a_stopped_run_is_still_optimized() {
let root = seed_text_tree("stop-optimize");
let (db_dir, db) = Scratch::db("stop-optimize-db");
let dir_key = db_dir.to_string_lossy().into_owned();
// Per-directory, so a sibling test optimizing its own scratch index on
// another thread cannot satisfy this.
let before = quicksearch_core::db::repo::optimize_count(&dir_key);
let service = start(&root, &db, &Config::default());
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline {
if wal_bytes(&db) > 512 * 1024 {
break;
}
if let IndexingStatus::Error(e) = service.get_status() {
panic!("indexing failed: {}", e);
}
std::thread::sleep(Duration::from_millis(2));
}
service.request_stop();
run_to_idle(&service, || {});
assert!(!stamped(&db), "the run finished before the stop landed");
assert_eq!(
quicksearch_core::db::repo::optimize_count(&dir_key),
before + 1,
"a stopped run must still run PRAGMA optimize against its index"
);
assert_eq!(
wal_bytes(&db),
0,
"the optimize pass must land the stopped run's log"
);
drop(service);
}
/// High-byte binaries (protobuf and friends: no NUL, no control bytes) clear
/// the binary guard; the tightened sniff must still refuse them. The row must
/// survive in `files` while acquiring no `documents_text` sidecar and no
/// `failed_files` entry — no text is not a failure — and the `.txt` twin with
/// the same bytes must still extract.
#[test]
fn high_byte_binaries_are_listed_but_not_text_extracted() {
let root = Scratch::dir("sniff-binary");
let (_db_dir, db) = Scratch::db("sniff-binary-db");
// Head of a real protobuf-framed GPS log: varint framing around ASCII
// NMEA sentences. No NUL, no control-byte density — it clears the binary
// guard on its own.
let mut pb = b"\x10\n\x02v1\x10\x01\x18\xe2\xe3\xfc\xd3\x9d\xca\x97\xe4\x189\x08".to_vec();
pb.extend_from_slice(b"\x12*$GNGGA,181558.00,,,,,0,00,99.99,,,,,,*78\r\n");
assert!(!pb.contains(&0u8), "fixture must not trip the NUL guard");
let legacy = b"Le caf\xe9 pr\xe8s de la fen\xeatre est agr\xe9able en \xe9t\xe9.";
touch(&root.join("rtk.pb"), &pb);
touch(&root.join("legacy.txt"), legacy);
touch(&root.join("notes.md"), b"ordinary utf-8 prose");
index_once(&root, &db, &Config::default());
let conn = rusqlite::Connection::open(&db).unwrap();
let probe = |suffix: &str| -> (i64, i64, i64) {
conn.query_row(
"SELECT f.content_state,
(SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id),
(SELECT COUNT(*) FROM failed_files x WHERE x.file_id = f.id)
FROM files f WHERE f.parent || f.name LIKE '%' || ?1",
[suffix],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap_or_else(|e| panic!("{suffix} must be indexed: {e}"))
};
// 3 = not applicable.
assert_eq!(
probe("rtk.pb"),
(3, 0, 0),
"a high-byte binary must be listed, not extracted, and not a failure"
);
let (state, sidecars, failures) = probe("legacy.txt");
assert_eq!(
(state, failures),
(1, 0),
"a legacy-charset .txt must still extract"
);
assert_eq!(sidecars, 1, "and must still store its text");
assert_eq!(probe("notes.md"), (1, 1, 0), "ordinary UTF-8 is unaffected");
}
/// Index `root` into `db` and run to `Idle`, sampling the log throughout;
/// returns its peak. Waits for `Idle` rather than the completion marker, which
/// lands inside `run_indexing` before the FTS merge, the tail checkpoints and
/// `repo::maintain`: the window the tail tests measure.
fn reindex_sampling_the_log(root: &Path, db: &Path, config: &Config) -> u64 {
let service = start(root, db, config);
let mut peak = 0u64;
run_to_idle(&service, || peak = peak.max(wal_bytes(db)));
drop(service);
peak
}
/// The log stays bounded after the writer loop, the window
/// [`the_wal_stays_bounded_during_a_run`] does not cover.
///
/// Everything after the writer loop is database work with no checkpoint of its
/// own: the FTS merge, the completion stamp, the per-root counts, then
/// `repo::maintain`'s VACUUM, whose copy-back pushes the whole database
/// through the log. With autocheckpoint off for the run and every per-root
/// reader holding a read mark, all of it lands on one log unless the tail
/// checkpoints. A warm reindex writes almost nothing during the loop, so the
/// in-loop checkpoint never fires; without the tail's, one left a `-wal`
/// several times the size of the index.
///
/// A reader is held across both runs, and the test is vacuous without it. The
/// application always has one: the search worker keeps its connection for
/// `IDLE_RELEASE`. Without it the indexer's connection is the last handle on
/// the file, and SQLite checkpoints and deletes the log when the last one
/// closes, hiding anything the run failed to land.
///
/// Two assertions. The mechanism: the tail's checkpoints hand `repo::maintain`
/// an empty log, read back through `repo::log_on_entry_to_maintain`, a latch,
/// because the value is gone before a test could sample it; this fails without
/// the tail checkpoints. The peak guards the reported symptom, but a fixture
/// this size cannot build a tail large enough to breach the ceiling on its own,
/// so it pins the current behaviour rather than reproducing the bug.
#[test]
fn the_wal_stays_bounded_through_the_tail_of_a_warm_reindex() {
let root = seed_text_tree("wal-tail");
let (db_dir, db) = Scratch::db("wal-tail-db");
let dir_key = db_dir.to_string_lossy().into_owned();
// With positions: the log is measured against the index's size, and
// position lists make this fixture's index several times larger.
let mut config = Config::default();
config.processing.compact_text_index = false;
// An empty root first, purely to bring the index into existence so the
// reader below can be opened before the run that matters.
let empty = Scratch::dir("wal-tail-empty");
reindex_sampling_the_log(&empty, &db, &config);
let reader = rusqlite::Connection::open(&db).unwrap();
// Lazily attached: without a statement there is no handle on the file yet,
// and the point of this connection is to be one.
reader
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get::<_, i64>(0))
.unwrap();
// The cold run is where the mechanism is visible: it fills the log, and
// 30-odd MiB is far under the default cap, so the in-loop checkpoint
// never fires and the tail's is the only one there is.
reindex_sampling_the_log(&root, &db, &config);
let indexed = std::fs::metadata(&db).unwrap().len();
assert!(
indexed > 8 * 1024 * 1024,
"the fixture built a {} byte index; too small to measure a log against",
indexed
);
assert_eq!(
quicksearch_core::db::repo::log_on_entry_to_maintain(&dir_key),
Some(0),
"the tail must land its log before the pass that VACUUMs through it"
);
// Nothing on disk has changed, so every byte of log below is the tail's.
let peak = reindex_sampling_the_log(&root, &db, &config);
assert_eq!(
quicksearch_core::db::repo::log_on_entry_to_maintain(&dir_key),
Some(0),
"and a warm reindex's tail must land its own"
);
drop(reader);
// One VACUUM's copy-back is the largest thing the tail may legitimately
// write, plus the log's own 16 MiB floor. The bug cleared twice the index.
let ceiling = indexed + 16 * 1024 * 1024;
assert!(
peak < ceiling,
"the tail peaked at {} bytes of log against a {} byte index",
peak,
indexed
);
assert_eq!(wal_bytes(&db), 0, "and the tail leaves no log behind");
}
/// A reindex that finds nothing to do does not grow the FTS index.
///
/// The end-of-run merge takes a positive page budget. A negative one routes
/// through `fts5IndexOptimizeStruct`, a rate-limited `optimize` that hoists
/// every segment into one level and leaves the structure mid-merge in
/// `%_data`; called once per run, each run would restart it and churn pages
/// for an unchanged corpus.
#[test]
fn repeated_warm_reindexes_do_not_grow_the_fts_index() {
let root = seed_text_tree("fts-churn");
let (_db_dir, db) = Scratch::db("fts-churn-db");
let config = Config::default();
let fts_pages = |db: &Path| {
count(
db,
"SELECT COUNT(*) FROM dbstat WHERE name = 'searchabletext_data'",
)
};
reindex_sampling_the_log(&root, &db, &config);
let first = fts_pages(&db);
assert!(first > 0, "the fixture built no FTS index");
let mut sizes = vec![first];
for _ in 0..3 {
reindex_sampling_the_log(&root, &db, &config);
sizes.push(fts_pages(&db));
}
// Not equality: `PRAGMA optimize` and a merge that consolidates real
// segments may move the figure either way once. Monotonic growth over
// three no-op runs is the signature of a structure that never settles.
let last = *sizes.last().unwrap();
assert!(
last <= first,
"searchabletext_data grew across no-op reindexes: {:?} pages",
sizes
);
}
// ---------------------------------------------------------------------------
// Container size against extraction cost
// ---------------------------------------------------------------------------
/// A presentation carrying media: one slide of text inside megabytes of
/// picture, which is the shape every real deck has.
fn media_heavy_deck(path: &Path, media_bytes: usize) {
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("ppt/slides/slide1.xml", zip::write::FileOptions::default())
.unwrap();
std::io::Write::write_all(
&mut zip,
b"<p:sld><p:cSld><p:spTree><p:sp><p:txBody>\
<a:p><a:r><a:t>Quarterly chalcedony forecast</a:t></a:r></a:p>\
</p:txBody></p:sp></p:spTree></p:cSld></p:sld>",
)
.unwrap();
// Stored, not deflated: the file on disk has to really be this large, or
// the size gate under test never sees it.
zip.start_file(
"ppt/media/image1.png",
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
std::io::Write::write_all(&mut zip, &vec![0u8; media_bytes]).unwrap();
zip.finish().unwrap();
}
/// The content pass gates on a file's size, which for a container is its media,
/// not its text: a 5 MiB deck holds about 10 KiB of words, and reading them
/// costs a seek through the central directory plus the one member named. A cap
/// tuned to prose indexes every deck by name only, with nothing inside
/// findable and no failure to show for it.
#[test]
fn a_media_heavy_presentation_is_indexed_under_the_default_cap() {
let root = Scratch::dir("deck-media");
let (_db_dir, db) = Scratch::db("deck-media-db");
let deck = root.join("quarterly.pptx");
media_heavy_deck(&deck, 5 * 1024 * 1024);
let config = Config::default();
let size = std::fs::metadata(&deck).unwrap().len();
assert!(
size > 2 * 1024 * 1024,
"the fixture is only a test of the cap if it is large: {} bytes",
size
);
assert!(
size <= config.processing.maximum_text_file_size,
"a {} byte deck is over the {} byte default cap, so its text is skipped",
size,
config.processing.maximum_text_file_size
);
index_once(&root, &db, &config);
let text = stored_text(&db, "quarterly.pptx").expect("the deck stored no text at all");
assert!(
text.contains("Quarterly chalcedony forecast"),
"the slide's text is not what was stored: {:?}",
text
);
assert_eq!(
hits(&db, "chalcedony"),
1,
"a word inside the deck is not searchable"
);
}
/// Raising the cap has to reach an index that already wrote a file off. The
/// row is `NA` and nothing re-asks about a file until the file itself
/// changes, so without a reconcile the setting looks like it does nothing:
/// the deck stays findable by name only, with no failure to explain it.
#[test]
fn raising_the_size_cap_reaches_files_already_written_off() {
use std::sync::atomic::AtomicBool;
let root = Scratch::dir("cap-raise");
let (_db_dir, db) = Scratch::db("cap-raise-db");
media_heavy_deck(&root.join("quarterly.pptx"), 5 * 1024 * 1024);
// A cap under the deck's size: its text is never read. The root goes in
// the config too — `WorkCursor` scans the roots the config names, not
// the ones an indexing run was handed.
let mut tight = Config::default();
tight.paths.indexing_paths = vec![root.to_string_lossy().into_owned()];
tight.processing.maximum_text_file_size = 1024 * 1024;
index_once(&root, &db, &tight);
assert!(
stored_text(&db, "quarterly.pptx").is_none(),
"the fixture has to start out skipped, or this proves nothing"
);
// Raised, the way Settings raises it: one field, nothing else.
let mut raised = tight.clone();
raised.processing.maximum_text_file_size = Config::default().processing.maximum_text_file_size;
let work = quicksearch_core::config::diff_actions(&tight, &raised).work;
assert!(
work.reconcile_content && work.reindex,
"a raised cap must ask for both halves: {:?}",
work
);
let mut conn = rusqlite::Connection::open(&db).unwrap();
let registry = quicksearch_core::extract::Registry::default_set();
let mut cursor = quicksearch_core::scope::WorkCursor::new(work, &raised).unwrap();
let cancel = AtomicBool::new(false);
while !cursor.done() {
quicksearch_core::scope::advance(
&mut conn,
&raised,
&registry,
&mut cursor,
Instant::now(),
&cancel,
)
.unwrap();
}
drop(conn);
index_once(&root, &db, &raised);
let text = stored_text(&db, "quarterly.pptx")
.expect("the deck is still skipped after the cap was raised");
assert!(
text.contains("Quarterly chalcedony forecast"),
"the deck's text is not what was stored: {:?}",
text
);
}