Another performance pass: Batch writes and cut background work across indexing, search and the GUI
This commit is contained in:
parent
590ff9ad0b
commit
5bc2d53ad9
41 changed files with 1368 additions and 1356 deletions
|
|
@ -16,9 +16,8 @@ fn main() {
|
||||||
divan::main();
|
divan::main();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compressing an extracted document: pure CPU, done before the writer takes
|
/// Compressing an extracted document: pure CPU, done by the writer as each
|
||||||
/// the connection. `batch_*` measures a whole chunk, as `Bodies::fill`
|
/// row is stored. `batch_*` measures a whole walk batch of small bodies.
|
||||||
/// compresses one.
|
|
||||||
mod zstd_encode {
|
mod zstd_encode {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
@ -38,9 +37,8 @@ mod zstd_encode {
|
||||||
bencher.bench_local(move || enc.compress(divan::black_box(text)).unwrap());
|
bencher.bench_local(move || enc.compress(divan::black_box(text)).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One writer chunk of typical documents. A contention figure, not a
|
/// One walk batch of typical documents. All compression together is
|
||||||
/// throughput one: the lock serializes the indexer against itself, and
|
/// under 1% of a cold run.
|
||||||
/// all compression together is under 1% of a cold run.
|
|
||||||
const BATCH: usize = 500;
|
const BATCH: usize = 500;
|
||||||
|
|
||||||
#[divan::bench]
|
#[divan::bench]
|
||||||
|
|
@ -235,11 +233,9 @@ mod update_narrowing {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A watcher event for a directory whose files are all indexed and unchanged:
|
/// A watcher event for a directory of indexed files, applied in one
|
||||||
/// a re-sent `Create`, a `chmod -R`, the live view re-checking what it shows.
|
/// transaction as the coordinator applies a slice of its queue.
|
||||||
/// Nothing is written, so the cost is whatever it reads to learn that; the
|
mod directory_event {
|
||||||
/// full walk's answer is one `stat` per file.
|
|
||||||
mod unchanged_event {
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use quicksearch_core::config::{Config, IgnoreSet};
|
use quicksearch_core::config::{Config, IgnoreSet};
|
||||||
use quicksearch_core::extract::Registry;
|
use quicksearch_core::extract::Registry;
|
||||||
|
|
@ -251,8 +247,11 @@ mod unchanged_event {
|
||||||
|
|
||||||
const FILES: usize = 500;
|
const FILES: usize = 500;
|
||||||
|
|
||||||
|
/// Every file unchanged: a re-sent `Create`, a `chmod -R`, the live view
|
||||||
|
/// re-checking what it shows. Nothing is written, so the cost is whatever
|
||||||
|
/// it reads to learn that; the full walk's answer is one `stat` per file.
|
||||||
#[divan::bench]
|
#[divan::bench]
|
||||||
fn directory_create(bencher: Bencher) {
|
fn unchanged(bencher: Bencher) {
|
||||||
let (dir, db) = Scratch::db("bench-unchanged-event");
|
let (dir, db) = Scratch::db("bench-unchanged-event");
|
||||||
let tree = db.with_file_name("tree");
|
let tree = db.with_file_name("tree");
|
||||||
std::fs::create_dir_all(&tree).unwrap();
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
|
|
@ -277,11 +276,65 @@ mod unchanged_event {
|
||||||
cancel: &cancel,
|
cancel: &cancel,
|
||||||
resume_from: 0,
|
resume_from: 0,
|
||||||
};
|
};
|
||||||
apply_fs_event(&mut conn, &event, &config, &ignore, ®istry, &budget).unwrap()
|
// One transaction per event, as the coordinator's apply slice is.
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let applied =
|
||||||
|
apply_fs_event(&tx, &event, &config, &ignore, ®istry, &budget).unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
applied
|
||||||
};
|
};
|
||||||
// Indexed once; every timed run finds it unchanged.
|
// Indexed once; every timed run finds it unchanged.
|
||||||
apply();
|
apply();
|
||||||
bencher.bench_local(apply);
|
bencher.bench_local(apply);
|
||||||
drop(dir);
|
drop(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same directory with every file's mtime moved on before each run,
|
||||||
|
/// so each event rewrites all of its rows and their text: a checkout or
|
||||||
|
/// an unpack, where commit and FTS5 segment costs add up per file.
|
||||||
|
#[divan::bench]
|
||||||
|
fn changed(bencher: Bencher) {
|
||||||
|
let (dir, db) = Scratch::db("bench-changed-event");
|
||||||
|
let tree = db.with_file_name("tree");
|
||||||
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
|
let files: Vec<std::path::PathBuf> =
|
||||||
|
(0..FILES).map(|i| tree.join(format!("f{:04}.txt", i))).collect();
|
||||||
|
for file in &files {
|
||||||
|
std::fs::write(file, corpus::text(1 << 10, 4)).unwrap();
|
||||||
|
}
|
||||||
|
let mut conn =
|
||||||
|
quicksearch_core::db::open_or_recreate(db.to_str().unwrap(), &Default::default())
|
||||||
|
.unwrap();
|
||||||
|
let config = Config::default();
|
||||||
|
let ignore = IgnoreSet::compile(&config.indexing.ignore_patterns).unwrap();
|
||||||
|
let registry = Registry::default_set();
|
||||||
|
let event = FsEvent::Create(tree.clone());
|
||||||
|
let cancel = AtomicBool::new(false);
|
||||||
|
let mut apply = move || {
|
||||||
|
let budget = Budget {
|
||||||
|
deadline: Instant::now() + Duration::from_secs(3600),
|
||||||
|
cancel: &cancel,
|
||||||
|
resume_from: 0,
|
||||||
|
};
|
||||||
|
// One transaction per event, as the coordinator's apply slice is.
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let applied =
|
||||||
|
apply_fs_event(&tx, &event, &config, &ignore, ®istry, &budget).unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
applied
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
let mut tick = 0u64;
|
||||||
|
bencher
|
||||||
|
.with_inputs(|| {
|
||||||
|
tick += 1;
|
||||||
|
let at = std::time::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + tick);
|
||||||
|
for file in &files {
|
||||||
|
let f = std::fs::File::options().write(true).open(file).unwrap();
|
||||||
|
f.set_modified(at).unwrap();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.bench_local_values(|()| apply());
|
||||||
|
drop(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,12 +157,12 @@ fn walk_traffic_per_file(samples: &[corpus::Sample], config: &Config, registry:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows per writer chunk: `batch::STORE_CHUNK`, the unit `Bodies::fill` is
|
/// Rows per writer turn: `pipeline::READY_TOPUP`, the most one
|
||||||
/// handed.
|
/// `store_extracted` call is handed.
|
||||||
const CHUNK: usize = 32;
|
const CHUNK: usize = 64;
|
||||||
|
|
||||||
/// The writer's half that runs outside the connection lock: one output
|
/// The writer's compression: one output `Vec<u8>` per row against the run's
|
||||||
/// `Vec<u8>` per row against one arena per chunk.
|
/// one encoder and its reused buffer.
|
||||||
///
|
///
|
||||||
/// The zstd context does not appear here: `zstd::bulk::Compressor::new`
|
/// The zstd context does not appear here: `zstd::bulk::Compressor::new`
|
||||||
/// allocates through zstd's own C allocator, so its cost is CPU only
|
/// allocates through zstd's own C allocator, so its cost is CPU only
|
||||||
|
|
@ -186,17 +186,15 @@ fn compression_traffic_per_chunk(config: &Config) {
|
||||||
.map(|_| repo::encode_one(&doc).expect("encode"))
|
.map(|_| repo::encode_one(&doc).expect("encode"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
// What `Bodies::fill` does: one encoder and one arena for the chunk.
|
// What `store_extracted` does: one encoder, its buffer reused per row.
|
||||||
let arena = measure(|| {
|
let reused = measure(|| {
|
||||||
let mut enc = DocEncoder::new().expect("encoder");
|
let mut enc = DocEncoder::new().expect("encoder");
|
||||||
let mut arena = Vec::new();
|
|
||||||
(0..CHUNK)
|
(0..CHUNK)
|
||||||
.map(|_| enc.encode_into(&doc, &mut arena).expect("encode"))
|
.map(|_| enc.body(&doc).expect("encode").map_or(0, <[u8]>::len))
|
||||||
.collect::<Vec<_>>()
|
.sum::<usize>()
|
||||||
.len()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (shape, usage) in [("a Vec per row", per_row), ("one arena, chunk", arena)] {
|
for (shape, usage) in [("a Vec per row", per_row), ("one encoder, reused", reused)] {
|
||||||
println!(
|
println!(
|
||||||
"{:<24} {:>10} {:>10} {:>12} {:>12}",
|
"{:<24} {:>10} {:>10} {:>12} {:>12}",
|
||||||
shape,
|
shape,
|
||||||
|
|
@ -207,8 +205,8 @@ fn compression_traffic_per_chunk(config: &Config) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"\n The arena is reused across every chunk a writer call handles, so after the\n \
|
"\n The encoder lives for the whole run, so after the first turn its growth is\n \
|
||||||
first its growth is zero too. The zstd context is invisible here — see above.\n"
|
zero too. The zstd context is invisible here — see above.\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,39 +165,40 @@ mod substring {
|
||||||
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `pass_fulltext` runs per row on the literal path: a
|
/// What `pass_fulltext` runs per row on the literal path: one folded
|
||||||
/// case-sensitive count, then one folded extraction yielding both.
|
/// extraction yielding the window and both counts.
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
#[divan::bench(args = corpus::SIZES)]
|
||||||
fn cascade_row_sweeps(bencher: Bencher, size: usize) {
|
fn cascade_row_sweeps(bencher: Bencher, size: usize) {
|
||||||
let pattern = literal(corpus::NEEDLE);
|
|
||||||
let text = corpus::text_mixed(size, 4);
|
let text = corpus::text_mixed(size, 4);
|
||||||
let folded = corpus::text_folded(size, 4);
|
let folded = corpus::text_folded(size, 4);
|
||||||
let opts = snippet::Options { approx_bytes: 600 };
|
let term = corpus::NEEDLE.to_ascii_lowercase();
|
||||||
|
let budget = 600;
|
||||||
bencher.bench(|| {
|
bencher.bench(|| {
|
||||||
let (s, b) = snippet::extract_folded(
|
snippet::extract_folded(
|
||||||
divan::black_box(text),
|
divan::black_box(text),
|
||||||
divan::black_box(folded),
|
divan::black_box(folded),
|
||||||
|
&term,
|
||||||
corpus::NEEDLE,
|
corpus::NEEDLE,
|
||||||
&opts,
|
budget,
|
||||||
);
|
)
|
||||||
let a = pattern.count(text);
|
.map(|f| (f.count, f.exact, f.snippet.ranges.len()))
|
||||||
(a, b, s.ranges.len())
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rejected shape: a separate folded count sweeps the document a
|
/// The replaced shape: a separate case-sensitive count sweeps the
|
||||||
/// third time for a number the extraction already has.
|
/// document a second time for a number the extraction now has.
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
#[divan::bench(args = corpus::SIZES)]
|
||||||
fn cascade_row_sweeps_separate_count(bencher: Bencher, size: usize) {
|
fn cascade_row_sweeps_separate_count(bencher: Bencher, size: usize) {
|
||||||
let pattern = literal(corpus::NEEDLE);
|
|
||||||
let text = corpus::text_mixed(size, 4);
|
let text = corpus::text_mixed(size, 4);
|
||||||
let folded = corpus::text_folded(size, 4);
|
let folded = corpus::text_folded(size, 4);
|
||||||
let opts = snippet::Options { approx_bytes: 600 };
|
let term = corpus::NEEDLE.to_ascii_lowercase();
|
||||||
|
let budget = 600;
|
||||||
bencher.bench(|| {
|
bencher.bench(|| {
|
||||||
let a = pattern.count(divan::black_box(text));
|
let exact =
|
||||||
let b = pattern.count_folded(divan::black_box(folded));
|
memchr::memmem::find_iter(divan::black_box(text).as_bytes(), corpus::NEEDLE)
|
||||||
let (s, _) = snippet::extract_folded(text, folded, corpus::NEEDLE, &opts);
|
.count();
|
||||||
(a, b, s.ranges.len())
|
let f = snippet::extract_folded(text, folded, &term, corpus::NEEDLE, budget);
|
||||||
|
(exact, f.map(|f| (f.count, f.snippet.ranges.len())))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,13 +212,15 @@ mod snippet_extract {
|
||||||
fn extract_folded(bencher: Bencher, size: usize) {
|
fn extract_folded(bencher: Bencher, size: usize) {
|
||||||
let text = corpus::text_mixed(size, 64);
|
let text = corpus::text_mixed(size, 64);
|
||||||
let folded = corpus::text_folded(size, 64);
|
let folded = corpus::text_folded(size, 64);
|
||||||
let opts = snippet::Options { approx_bytes: 600 };
|
let term = corpus::NEEDLE.to_ascii_lowercase();
|
||||||
|
let budget = 600;
|
||||||
bencher.bench(|| {
|
bencher.bench(|| {
|
||||||
snippet::extract_folded(
|
snippet::extract_folded(
|
||||||
divan::black_box(text),
|
divan::black_box(text),
|
||||||
divan::black_box(folded),
|
divan::black_box(folded),
|
||||||
|
&term,
|
||||||
corpus::NEEDLE,
|
corpus::NEEDLE,
|
||||||
&opts,
|
budget,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +252,6 @@ mod filename_ladder {
|
||||||
|
|
||||||
#[divan::bench]
|
#[divan::bench]
|
||||||
fn find_first_ci_scratch(bencher: Bencher) {
|
fn find_first_ci_scratch(bencher: Bencher) {
|
||||||
let pattern = literal("quartzite");
|
|
||||||
let rows = corpus::rows();
|
let rows = corpus::rows();
|
||||||
let mut scratch = String::new();
|
let mut scratch = String::new();
|
||||||
bencher.bench_local(move || {
|
bencher.bench_local(move || {
|
||||||
|
|
@ -258,14 +260,14 @@ mod filename_ladder {
|
||||||
scratch.clear();
|
scratch.clear();
|
||||||
scratch.push_str(&row.name);
|
scratch.push_str(&row.name);
|
||||||
scratch.make_ascii_lowercase();
|
scratch.make_ascii_lowercase();
|
||||||
if pattern.find_first_folded(&scratch).is_some() {
|
if memchr::memmem::find(scratch.as_bytes(), b"quartzite").is_some() {
|
||||||
found += 1;
|
found += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
scratch.clear();
|
scratch.clear();
|
||||||
scratch.push_str(&row.path);
|
scratch.push_str(&row.path);
|
||||||
scratch.make_ascii_lowercase();
|
scratch.make_ascii_lowercase();
|
||||||
if pattern.find_first_folded(&scratch).is_some() {
|
if memchr::memmem::find(scratch.as_bytes(), b"quartzite").is_some() {
|
||||||
found += 1;
|
found += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,11 @@
|
||||||
|
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Instant, UNIX_EPOCH};
|
use std::time::Instant;
|
||||||
|
|
||||||
use quicksearch_core::config::{Config, IgnoreSet};
|
use quicksearch_core::config::{Config, IgnoreSet};
|
||||||
use quicksearch_core::extract::Registry;
|
use quicksearch_core::extract::Registry;
|
||||||
use quicksearch_core::file_handling::{
|
use quicksearch_core::file_handling::{filtered_walk, prepare_file_record, UnreadableDirs};
|
||||||
classify_for_indexing, filtered_walk, prepare_file_record, DirRows, FileIndexAction,
|
|
||||||
UnreadableDirs,
|
|
||||||
};
|
|
||||||
use quicksearch_core::walk::{walk_indexable_files, WalkEvent};
|
use quicksearch_core::walk::{walk_indexable_files, WalkEvent};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -42,11 +39,10 @@ fn main() {
|
||||||
let _ = std::fs::remove_file(&db);
|
let _ = std::fs::remove_file(&db);
|
||||||
quicksearch_core::db::open_or_recreate(db.to_str().unwrap(), &config.processing)
|
quicksearch_core::db::open_or_recreate(db.to_str().unwrap(), &config.processing)
|
||||||
.expect("scratch index");
|
.expect("scratch index");
|
||||||
let existing = DirRows::new();
|
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let (seen, prepared) = match mode.as_str() {
|
let (seen, prepared) = match mode.as_str() {
|
||||||
"serial" => serial(&root, &config, &existing),
|
"serial" => serial(&root, &config),
|
||||||
_ => parallel(&root, &config, db.to_str().unwrap()),
|
_ => parallel(&root, &config, db.to_str().unwrap()),
|
||||||
};
|
};
|
||||||
let _ = std::fs::remove_file(&db);
|
let _ = std::fs::remove_file(&db);
|
||||||
|
|
@ -59,7 +55,8 @@ fn main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
|
/// Against the same empty index, so every file is new and gets prepared.
|
||||||
|
fn serial(root: &str, config: &Config) -> (usize, usize) {
|
||||||
let ignore = IgnoreSet::compile(&[]).unwrap();
|
let ignore = IgnoreSet::compile(&[]).unwrap();
|
||||||
let registry = Registry::default_set();
|
let registry = Registry::default_set();
|
||||||
// One for the whole walk, as a walk worker holds one.
|
// One for the whole walk, as a walk worker holds one.
|
||||||
|
|
@ -74,21 +71,7 @@ fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
|
||||||
let Ok(meta) = std::fs::metadata(entry.path()) else {
|
let Ok(meta) = std::fs::metadata(entry.path()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(mtime) = meta
|
if prepare_file_record(&path, &meta, config, ®istry, &mut scratch).is_some() {
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let name = std::path::Path::new(&path)
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if classify_for_indexing(&name, mtime, existing) != FileIndexAction::Skip
|
|
||||||
&& prepare_file_record(&path, &meta, config, ®istry, &mut scratch).is_some()
|
|
||||||
{
|
|
||||||
prepared += 1;
|
prepared += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,16 +331,14 @@ mod tests {
|
||||||
|
|
||||||
use crate::db::open_or_recreate;
|
use crate::db::open_or_recreate;
|
||||||
use crate::db::repo::{self, insert_file, NewFile};
|
use crate::db::repo::{self, insert_file, NewFile};
|
||||||
use crate::file_handling::{
|
use crate::file_handling::{extract_scope_prepare, store_extracted, ExtractScope, Stored};
|
||||||
extract_scope_prepare, store_extracted, Bodies, ExtractScope, Stored,
|
|
||||||
};
|
|
||||||
use crate::mime::FileType;
|
use crate::mime::FileType;
|
||||||
use crate::testutil::Scratch;
|
use crate::testutil::Scratch;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
fn bodies() -> Bodies {
|
fn encoder() -> crate::db::repo::DocEncoder {
|
||||||
Bodies::new().unwrap()
|
crate::db::repo::DocEncoder::new().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tree(s: &Scratch) -> PathBuf {
|
fn tree(s: &Scratch) -> PathBuf {
|
||||||
|
|
@ -428,7 +426,7 @@ mod tests {
|
||||||
assert!(pass.finish());
|
assert!(pass.finish());
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let far = Instant::now() + Duration::from_secs(60);
|
let far = Instant::now() + Duration::from_secs(60);
|
||||||
store_extracted(conn, &rows, &stop, far, &mut bodies()).unwrap()
|
store_extracted(conn, &rows, &stop, far, &mut encoder()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -552,7 +550,7 @@ mod tests {
|
||||||
// A deadline already gone by: one row, then out.
|
// A deadline already gone by: one row, then out.
|
||||||
let past = Instant::now() - Duration::from_secs(1);
|
let past = Instant::now() - Duration::from_secs(1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store_extracted(&conn_mutex, &rows, &stop, past, &mut bodies()).unwrap(),
|
store_extracted(&conn_mutex, &rows, &stop, past, &mut encoder()).unwrap(),
|
||||||
Stored {
|
Stored {
|
||||||
consumed: 1,
|
consumed: 1,
|
||||||
written: 1
|
written: 1
|
||||||
|
|
@ -560,7 +558,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
let far = Instant::now() + Duration::from_secs(60);
|
let far = Instant::now() + Duration::from_secs(60);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store_extracted(&conn_mutex, &rows[1..], &stop, far, &mut bodies()).unwrap(),
|
store_extracted(&conn_mutex, &rows[1..], &stop, far, &mut encoder()).unwrap(),
|
||||||
Stored {
|
Stored {
|
||||||
consumed: 4,
|
consumed: 4,
|
||||||
written: 4
|
written: 4
|
||||||
|
|
@ -580,7 +578,7 @@ mod tests {
|
||||||
// Stopped before it starts: nothing consumed.
|
// Stopped before it starts: nothing consumed.
|
||||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store_extracted(&conn_mutex, &rows, &stop, far, &mut bodies()).unwrap(),
|
store_extracted(&conn_mutex, &rows, &stop, far, &mut encoder()).unwrap(),
|
||||||
Stored::default()
|
Stored::default()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ use inner::Inner;
|
||||||
/// Pending-event ceiling; beyond this a full run is cheaper than replay.
|
/// Pending-event ceiling; beyond this a full run is cheaper than replay.
|
||||||
const PENDING_OVERFLOW: usize = 100_000;
|
const PENDING_OVERFLOW: usize = 100_000;
|
||||||
|
|
||||||
const FILE_COUNT_INTERVAL: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum IndexMode {
|
pub enum IndexMode {
|
||||||
Auto,
|
Auto,
|
||||||
|
|
@ -211,7 +209,7 @@ impl IndexCoordinator {
|
||||||
watcher_rx: None,
|
watcher_rx: None,
|
||||||
watcher_gen: 0,
|
watcher_gen: 0,
|
||||||
pending: inner::Debounced::default(),
|
pending: inner::Debounced::default(),
|
||||||
targeted: HashMap::new(),
|
targeted: inner::Debounced::default(),
|
||||||
resume_from: HashMap::new(),
|
resume_from: HashMap::new(),
|
||||||
needs_full_run: false,
|
needs_full_run: false,
|
||||||
run_retry: inner::Backoff::default(),
|
run_retry: inner::Backoff::default(),
|
||||||
|
|
@ -219,7 +217,7 @@ impl IndexCoordinator {
|
||||||
reconcile_done: None,
|
reconcile_done: None,
|
||||||
reconcile_cut_short: false,
|
reconcile_cut_short: false,
|
||||||
run_in_flight: false,
|
run_in_flight: false,
|
||||||
files_at: None,
|
files_stale: true,
|
||||||
was_busy: false,
|
was_busy: false,
|
||||||
write_conn: None,
|
write_conn: None,
|
||||||
ignore: Arc::new(IgnoreSet::compile(&[]).expect("empty ignore set")),
|
ignore: Arc::new(IgnoreSet::compile(&[]).expect("empty ignore set")),
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ const RUN_RETRY_BASE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
const RUN_RETRY_MAX: Duration = Duration::from_secs(300);
|
const RUN_RETRY_MAX: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
/// The watcher's event queue and the two timers that decide when to apply
|
/// An event queue and the two timers that decide when to apply it: once no
|
||||||
/// it: once no event has arrived for `pending_settle`, or once the oldest
|
/// event has arrived for `pending_settle`, or once the oldest has waited
|
||||||
/// has waited `pending_max_defer`, so a trickle cannot defer it forever.
|
/// `pending_max_defer`, so a trickle cannot defer it forever. One for the
|
||||||
|
/// watcher's events, one for the paths a frontend is displaying.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct Debounced {
|
pub(super) struct Debounced {
|
||||||
pub(super) events: HashMap<PathBuf, FsEvent>,
|
pub(super) events: HashMap<PathBuf, FsEvent>,
|
||||||
|
|
@ -107,6 +108,17 @@ impl Backoff {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `new` changes anything [`Inner::start_watcher`] registers with.
|
||||||
|
/// Every other edit (theme, shortcut, search limits) keeps the running
|
||||||
|
/// watcher: restarting it re-walks every directory under every root to
|
||||||
|
/// re-register its watch, and misses whatever changes in the gap.
|
||||||
|
pub(super) fn watch_inputs_differ(old: &Config, new: &Config) -> bool {
|
||||||
|
old.resolved_indexing_paths() != new.resolved_indexing_paths()
|
||||||
|
|| old.indexing.include_hidden != new.indexing.include_hidden
|
||||||
|
|| old.indexing.follow_symlinks != new.indexing.follow_symlinks
|
||||||
|
|| old.indexing.ignore_patterns != new.indexing.ignore_patterns
|
||||||
|
}
|
||||||
|
|
||||||
/// Log a failed apply of `what` by the queue's policy: a targeted failure
|
/// Log a failed apply of `what` by the queue's policy: a targeted failure
|
||||||
/// only logs, a pending one also schedules a full run. `true` for the latter.
|
/// only logs, a pending one also schedules a full run. `true` for the latter.
|
||||||
fn apply_failed(targeted: bool, what: &str, e: impl std::fmt::Display) -> bool {
|
fn apply_failed(targeted: bool, what: &str, e: impl std::fmt::Display) -> bool {
|
||||||
|
|
@ -149,7 +161,7 @@ pub(super) struct Inner {
|
||||||
/// Paths a frontend asked for by name ([`IndexCoordinator::update_paths`]).
|
/// Paths a frontend asked for by name ([`IndexCoordinator::update_paths`]).
|
||||||
/// Survives [`Inner::clear_pending`] and applies in manual mode: it keeps
|
/// Survives [`Inner::clear_pending`] and applies in manual mode: it keeps
|
||||||
/// the rows a user is *reading* in step with the disk.
|
/// the rows a user is *reading* in step with the disk.
|
||||||
pub(super) targeted: HashMap<PathBuf, FsEvent>,
|
pub(super) targeted: Debounced,
|
||||||
/// How far a directory event got before its turn's budget ran out.
|
/// How far a directory event got before its turn's budget ran out.
|
||||||
pub(super) resume_from: HashMap<PathBuf, usize>,
|
pub(super) resume_from: HashMap<PathBuf, usize>,
|
||||||
pub(super) needs_full_run: bool,
|
pub(super) needs_full_run: bool,
|
||||||
|
|
@ -163,7 +175,9 @@ pub(super) struct Inner {
|
||||||
/// Set while a full run holds the database; cleared by the tick that sees
|
/// Set while a full run holds the database; cleared by the tick that sees
|
||||||
/// it end.
|
/// it end.
|
||||||
pub(super) run_in_flight: bool,
|
pub(super) run_in_flight: bool,
|
||||||
pub(super) files_at: Option<Instant>,
|
/// The published row count may be behind the database: set by anything
|
||||||
|
/// that could have written, cleared by [`Inner::refresh_file_count`].
|
||||||
|
pub(super) files_stale: bool,
|
||||||
/// Drives [`Inner::go_idle`] once per busy→idle transition.
|
/// Drives [`Inner::go_idle`] once per busy→idle transition.
|
||||||
pub(super) was_busy: bool,
|
pub(super) was_busy: bool,
|
||||||
pub(super) write_conn: Option<Connection>,
|
pub(super) write_conn: Option<Connection>,
|
||||||
|
|
@ -207,13 +221,14 @@ impl Inner {
|
||||||
CoordCmd::ConfigChanged(new) => {
|
CoordCmd::ConfigChanged(new) => {
|
||||||
let want_auto = new.indexing.auto_index;
|
let want_auto = new.indexing.auto_index;
|
||||||
let actions = diff_actions(&self.config, &new);
|
let actions = diff_actions(&self.config, &new);
|
||||||
|
let rewatch = self.watcher.is_none() || watch_inputs_differ(&self.config, &new);
|
||||||
self.config = new;
|
self.config = new;
|
||||||
if let Err(e) = self.reload_filters() {
|
if let Err(e) = self.reload_filters() {
|
||||||
crate::log_warn!("coordinator: {}", e);
|
crate::log_warn!("coordinator: {}", e);
|
||||||
}
|
}
|
||||||
// The write connection may point at an old database_path.
|
// The write connection may point at an old database_path.
|
||||||
self.write_conn = None;
|
self.write_conn = None;
|
||||||
self.files_at = None;
|
self.files_stale = true;
|
||||||
if !actions.requires_rebuild && !actions.work.is_empty() {
|
if !actions.requires_rebuild && !actions.work.is_empty() {
|
||||||
self.start_work(actions.work);
|
self.start_work(actions.work);
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +236,8 @@ impl Inner {
|
||||||
(true, false) => self.enter_auto(),
|
(true, false) => self.enter_auto(),
|
||||||
(false, true) => self.enter_manual_stopped(),
|
(false, true) => self.enter_manual_stopped(),
|
||||||
// Still automatic: the watcher follows the new config.
|
// Still automatic: the watcher follows the new config.
|
||||||
(true, true) => self.start_watcher(),
|
(true, true) if rewatch => self.start_watcher(),
|
||||||
|
(true, true) => {}
|
||||||
(false, false) => {}
|
(false, false) => {}
|
||||||
}
|
}
|
||||||
self.refresh_last_full_index();
|
self.refresh_last_full_index();
|
||||||
|
|
@ -229,7 +245,7 @@ impl Inner {
|
||||||
CoordCmd::RebuildIndex => {
|
CoordCmd::RebuildIndex => {
|
||||||
let db = self.db_path();
|
let db = self.db_path();
|
||||||
self.write_conn = None;
|
self.write_conn = None;
|
||||||
self.files_at = None;
|
self.files_stale = true;
|
||||||
self.pending_work = None;
|
self.pending_work = None;
|
||||||
self.reconcile_done = None;
|
self.reconcile_done = None;
|
||||||
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
|
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
|
||||||
|
|
@ -260,7 +276,7 @@ impl Inner {
|
||||||
shared.files = Some(0);
|
shared.files = Some(0);
|
||||||
shared.root_counts = Arc::new(Vec::new());
|
shared.root_counts = Arc::new(Vec::new());
|
||||||
drop(shared);
|
drop(shared);
|
||||||
self.files_at = None;
|
self.files_stale = true;
|
||||||
}
|
}
|
||||||
CoordCmd::UpdatePaths(paths) => {
|
CoordCmd::UpdatePaths(paths) => {
|
||||||
// Only paths under an indexed root: nothing downstream checks,
|
// Only paths under an indexed root: nothing downstream checks,
|
||||||
|
|
@ -275,11 +291,8 @@ impl Inner {
|
||||||
let spelled = path.to_string_lossy();
|
let spelled = path.to_string_lossy();
|
||||||
prefixes.iter().any(|lo| spelled.starts_with(lo.as_str()))
|
prefixes.iter().any(|lo| spelled.starts_with(lo.as_str()))
|
||||||
};
|
};
|
||||||
paths
|
self.targeted
|
||||||
.into_iter()
|
.push(paths.into_iter().filter(under_root).filter_map(verb_for));
|
||||||
.filter(under_root)
|
|
||||||
.filter_map(verb_for)
|
|
||||||
.for_each(|event| enqueue(&mut self.targeted, event));
|
|
||||||
self.was_busy = true;
|
self.was_busy = true;
|
||||||
}
|
}
|
||||||
CoordCmd::Shutdown => unreachable!("handled in run()"),
|
CoordCmd::Shutdown => unreachable!("handled in run()"),
|
||||||
|
|
@ -313,7 +326,7 @@ impl Inner {
|
||||||
self.defer_runs();
|
self.defer_runs();
|
||||||
}
|
}
|
||||||
self.refresh_last_full_index();
|
self.refresh_last_full_index();
|
||||||
self.files_at = None;
|
self.files_stale = true;
|
||||||
if self.mode == IndexMode::ManualRunning {
|
if self.mode == IndexMode::ManualRunning {
|
||||||
self.mode = IndexMode::ManualStopped;
|
self.mode = IndexMode::ManualStopped;
|
||||||
}
|
}
|
||||||
|
|
@ -321,8 +334,10 @@ impl Inner {
|
||||||
|
|
||||||
self.refresh_file_count();
|
self.refresh_file_count();
|
||||||
|
|
||||||
// Ahead of every gate: rows a user is looking at right now.
|
// Ahead of every gate: rows a user is looking at right now. Settled
|
||||||
if !self.targeted.is_empty() {
|
// like the watcher's queue, or a growing file on screen, re-sent on
|
||||||
|
// every live refresh, is re-read, hashed and re-indexed every tick.
|
||||||
|
if !self.targeted.events.is_empty() && self.targeted.settled(&self.watcher_config) {
|
||||||
self.apply_targeted();
|
self.apply_targeted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,6 +384,8 @@ impl Inner {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.was_busy = false;
|
self.was_busy = false;
|
||||||
|
// Whatever made it busy may have added or removed rows.
|
||||||
|
self.files_stale = true;
|
||||||
self.write_conn = None;
|
self.write_conn = None;
|
||||||
crate::platform::release_free_heap();
|
crate::platform::release_free_heap();
|
||||||
}
|
}
|
||||||
|
|
@ -486,7 +503,7 @@ impl Inner {
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
// Resume points for `targeted` survive: that queue outlives this call.
|
// Resume points for `targeted` survive: that queue outlives this call.
|
||||||
self.resume_from
|
self.resume_from
|
||||||
.retain(|p, _| self.targeted.contains_key(p));
|
.retain(|p, _| self.targeted.events.contains_key(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply as much of the queue as fits in [`APPLY_BUDGET`], removals first:
|
/// Apply as much of the queue as fits in [`APPLY_BUDGET`], removals first:
|
||||||
|
|
@ -510,49 +527,64 @@ impl Inner {
|
||||||
self.pending.after_partial_apply();
|
self.pending.after_partial_apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain one queue's removals, then its upserts, within [`APPLY_BUDGET`].
|
/// Drain one queue's removals, then its upserts, within [`APPLY_BUDGET`],
|
||||||
/// `targeted` picks the queue and the failure policy: pending failures
|
/// in one transaction. `targeted` picks the queue and the failure policy:
|
||||||
/// escalate to [`Inner::needs_full_run`], targeted ones only log.
|
/// pending failures escalate to [`Inner::needs_full_run`], targeted ones
|
||||||
|
/// only log. Measured against a transaction per file (`benches/index.rs`,
|
||||||
|
/// `directory_event`, one event over 500 small text files): 250 →
|
||||||
|
/// 88 ms with every file changed, 1.55 → 1.09 ms with none.
|
||||||
fn apply_queue(&mut self, conn: &mut Connection, targeted: bool) {
|
fn apply_queue(&mut self, conn: &mut Connection, targeted: bool) {
|
||||||
let deadline = Instant::now() + APPLY_BUDGET;
|
let deadline = Instant::now() + APPLY_BUDGET;
|
||||||
let chunk = self.config.processing.batch_size.max(1);
|
let chunk = self.config.processing.batch_size.max(1);
|
||||||
|
let tx = match conn.transaction() {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(e) => {
|
||||||
|
self.needs_full_run |= apply_failed(targeted, "begin", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let queue = if targeted {
|
let queue = if targeted {
|
||||||
&mut self.targeted
|
&mut self.targeted.events
|
||||||
} else {
|
} else {
|
||||||
&mut self.pending.events
|
&mut self.pending.events
|
||||||
};
|
};
|
||||||
|
|
||||||
let removals: Vec<PathBuf> = queue
|
// Moved out, not cloned: what the budget leaves goes back below, so a
|
||||||
.iter()
|
// storm's queue is not copied whole on every slice, and the map it
|
||||||
.filter(|(_, ev)| is_removal(ev))
|
// leaves behind holds only what is left. Measured (200k targeted
|
||||||
.map(|(p, _)| p.clone())
|
// removals): drained in 10.0 → 9.1 s, peak RSS 85 → 81 MB.
|
||||||
.collect();
|
let (mut removals, mut upserts) = (Vec::new(), Vec::new());
|
||||||
|
for (path, ev) in std::mem::take(queue) {
|
||||||
|
if is_removal(&ev) {
|
||||||
|
removals.push(path);
|
||||||
|
} else {
|
||||||
|
upserts.push((path, ev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut removed = 0;
|
||||||
for batch in removals.chunks(chunk) {
|
for batch in removals.chunks(chunk) {
|
||||||
if let Err(e) = crate::incremental::remove_paths(conn, batch, chunk) {
|
if let Err(e) = crate::incremental::remove_paths(&tx, batch) {
|
||||||
// The batch leaves the queue either way: retrying a failed
|
// The batch leaves the queue either way: retrying a failed
|
||||||
// write every tick is worse, and the full run recovers the rows.
|
// write every tick is worse, and the full run recovers the rows.
|
||||||
self.needs_full_run |= apply_failed(targeted, "remove", e);
|
self.needs_full_run |= apply_failed(targeted, "remove", e);
|
||||||
}
|
}
|
||||||
for path in batch {
|
removed += batch.len();
|
||||||
queue.remove(path);
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
queue.extend(
|
||||||
|
removals
|
||||||
|
.drain(removed..)
|
||||||
|
.map(|path| (path.clone(), FsEvent::Remove(path))),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut upserts = upserts.into_iter();
|
||||||
if Instant::now() < deadline {
|
if Instant::now() < deadline {
|
||||||
let upserts: Vec<PathBuf> = queue
|
for (path, ev) in upserts.by_ref() {
|
||||||
.iter()
|
|
||||||
.filter(|(_, ev)| !is_removal(ev))
|
|
||||||
.map(|(p, _)| p.clone())
|
|
||||||
.collect();
|
|
||||||
for path in upserts {
|
|
||||||
let Some(ev) = queue.remove(&path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match apply_fs_event(
|
match apply_fs_event(
|
||||||
conn,
|
&tx,
|
||||||
&ev,
|
&ev,
|
||||||
&self.config,
|
&self.config,
|
||||||
&self.ignore,
|
&self.ignore,
|
||||||
|
|
@ -582,6 +614,10 @@ impl Inner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
queue.extend(upserts);
|
||||||
|
if let Err(e) = tx.commit() {
|
||||||
|
self.needs_full_run |= apply_failed(targeted, "commit", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the by-name queue: the paths a frontend is displaying. Never
|
/// Apply the by-name queue: the paths a frontend is displaying. Never
|
||||||
|
|
@ -603,6 +639,7 @@ impl Inner {
|
||||||
};
|
};
|
||||||
self.apply_queue(&mut conn, true);
|
self.apply_queue(&mut conn, true);
|
||||||
self.write_conn = Some(conn);
|
self.write_conn = Some(conn);
|
||||||
|
self.targeted.after_partial_apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_write_conn(&mut self) -> Result<Connection, String> {
|
fn ensure_write_conn(&mut self) -> Result<Connection, String> {
|
||||||
|
|
@ -684,8 +721,9 @@ impl Inner {
|
||||||
// target (`aliased_paths`), and those rows would leak until a rebuild.
|
// target (`aliased_paths`), and those rows would leak until a rebuild.
|
||||||
self.pending.keep_removals();
|
self.pending.keep_removals();
|
||||||
// A stale resume point would `skip` entries of an unrelated walk.
|
// A stale resume point would `skip` entries of an unrelated walk.
|
||||||
self.resume_from
|
self.resume_from.retain(|p, _| {
|
||||||
.retain(|p, _| self.pending.events.contains_key(p) || self.targeted.contains_key(p));
|
self.pending.events.contains_key(p) || self.targeted.events.contains_key(p)
|
||||||
|
});
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.indexing
|
.indexing
|
||||||
.start_indexing(roots, self.db_path(), self.config.clone())
|
.start_indexing(roots, self.db_path(), self.config.clone())
|
||||||
|
|
@ -864,8 +902,16 @@ impl Inner {
|
||||||
self.set_watcher_status(WatcherStatus::Off);
|
self.set_watcher_status(WatcherStatus::Off);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish `status`, waking the frontend when it changed: registration
|
||||||
|
/// can finish minutes after startup with no input to repaint on, and a
|
||||||
|
/// frontend polling for it would repaint the whole time.
|
||||||
fn set_watcher_status(&self, status: WatcherStatus) {
|
fn set_watcher_status(&self, status: WatcherStatus) {
|
||||||
crate::lock_ok(&self.shared).watcher = status;
|
let mut shared = crate::lock_ok(&self.shared);
|
||||||
|
if shared.watcher != status {
|
||||||
|
shared.watcher = status;
|
||||||
|
drop(shared);
|
||||||
|
(self.notify)();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db_path(&self) -> String {
|
fn db_path(&self) -> String {
|
||||||
|
|
@ -883,20 +929,18 @@ impl Inner {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the published row count, at most every [`FILE_COUNT_INTERVAL`].
|
/// Re-read the published row count once something may have changed it.
|
||||||
/// Called only from the idle half of [`Inner::tick`], so it cannot run
|
/// Called only from the idle half of [`Inner::tick`], so it cannot run
|
||||||
/// while a full run holds the database; `COUNT(*)` is still a key scan,
|
/// while a full run holds the database; `COUNT(*)` is still a key scan,
|
||||||
/// hence the interval and the interrupt guard.
|
/// hence the interrupt guard. It used to run every 30 s whatever the
|
||||||
|
/// mode, opening a connection each time to recount an unchanged index.
|
||||||
fn refresh_file_count(&mut self) {
|
fn refresh_file_count(&mut self) {
|
||||||
if self
|
if !self.files_stale {
|
||||||
.files_at
|
|
||||||
.is_some_and(|at| at.elapsed() < FILE_COUNT_INTERVAL)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Stamped before the read: a failing count must back off like a
|
// Cleared before the read: a failing count must not retry every
|
||||||
// successful one, or a missing index means an open attempt every tick.
|
// tick, or a missing index means an open attempt every second.
|
||||||
self.files_at = Some(Instant::now());
|
self.files_stale = false;
|
||||||
|
|
||||||
let Ok(conn) = db::open_existing(&self.db_path(), false) else {
|
let Ok(conn) = db::open_existing(&self.db_path(), false) else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -962,7 +1006,7 @@ impl Inner {
|
||||||
let busy = reconcile.is_some() || self.indexing.is_active();
|
let busy = reconcile.is_some() || self.indexing.is_active();
|
||||||
let mut shared = crate::lock_ok(&self.shared);
|
let mut shared = crate::lock_ok(&self.shared);
|
||||||
shared.mode = self.mode;
|
shared.mode = self.mode;
|
||||||
shared.queued_events = self.pending.events.len() + self.targeted.len();
|
shared.queued_events = self.pending.events.len() + self.targeted.events.len();
|
||||||
shared.reconcile = reconcile;
|
shared.reconcile = reconcile;
|
||||||
drop(shared);
|
drop(shared);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,12 +350,10 @@ fn stopping_cancels_a_queued_walk_but_not_a_queued_prune() {
|
||||||
coord.shutdown();
|
coord.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short debounce windows so trailing-edge events flush within test
|
/// Short debounce windows so trailing-edge events apply within test
|
||||||
/// timeouts (production defaults are 30 s / 2 s).
|
/// timeouts (production defaults are 2 s / 30 s).
|
||||||
fn fast_watcher() -> WatcherConfig {
|
fn fast_watcher() -> WatcherConfig {
|
||||||
WatcherConfig {
|
WatcherConfig {
|
||||||
throttle_window: Duration::from_millis(300),
|
|
||||||
tick_interval: Duration::from_millis(100),
|
|
||||||
pending_settle: Duration::from_millis(200),
|
pending_settle: Duration::from_millis(200),
|
||||||
pending_max_defer: Duration::from_secs(3),
|
pending_max_defer: Duration::from_secs(3),
|
||||||
..WatcherConfig::default()
|
..WatcherConfig::default()
|
||||||
|
|
@ -473,6 +471,38 @@ fn update_paths_indexes_one_file_with_indexing_stopped() {
|
||||||
coord.shutdown();
|
coord.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path re-sent on every live refresh, as a growing file on screen is,
|
||||||
|
/// waits for the queue to settle instead of being re-indexed every tick.
|
||||||
|
#[test]
|
||||||
|
fn update_paths_waits_for_the_queue_to_settle() {
|
||||||
|
let f = Fixture::new(false);
|
||||||
|
std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap();
|
||||||
|
f.seed_index();
|
||||||
|
let settle = WatcherConfig {
|
||||||
|
pending_settle: Duration::from_secs(1),
|
||||||
|
pending_max_defer: Duration::from_secs(20),
|
||||||
|
..WatcherConfig::default()
|
||||||
|
};
|
||||||
|
let coord = start_coord_watching(f.config.clone(), settle);
|
||||||
|
let added = f.dir.join("growing.log");
|
||||||
|
std::fs::write(&added, "first line").unwrap();
|
||||||
|
|
||||||
|
let spam_until = std::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while std::time::Instant::now() < spam_until {
|
||||||
|
coord.update_paths(vec![added.clone()]);
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
f.stored_mtime(&added),
|
||||||
|
None,
|
||||||
|
"applied while the path was still being re-sent"
|
||||||
|
);
|
||||||
|
wait_for("the settled insert", Duration::from_secs(20), || {
|
||||||
|
f.stored_mtime(&added).is_some()
|
||||||
|
});
|
||||||
|
coord.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_paths_leaves_a_row_that_already_agrees_alone() {
|
fn update_paths_leaves_a_row_that_already_agrees_alone() {
|
||||||
let f = Fixture::new(false);
|
let f = Fixture::new(false);
|
||||||
|
|
@ -582,6 +612,38 @@ fn auto_mode_runs_initial_index_and_applies_watcher_events() {
|
||||||
coord.shutdown();
|
coord.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The published count follows the coordinator's own writes, and an idle
|
||||||
|
/// coordinator does not go back to recount: a row slipped in behind its back
|
||||||
|
/// stays unreported until something it does changes the index.
|
||||||
|
#[test]
|
||||||
|
fn the_file_count_is_refreshed_by_writes_not_by_time() {
|
||||||
|
let f = Fixture::new(true);
|
||||||
|
std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap();
|
||||||
|
let coord = start_coord_watching(f.config.clone(), fast_watcher());
|
||||||
|
wait_for("initial auto index", Duration::from_secs(20), || {
|
||||||
|
coord.state().files == Some(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
std::fs::write(f.dir.join("later.txt"), "arrived later").unwrap();
|
||||||
|
wait_for(
|
||||||
|
"the count to follow an incremental add",
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| coord.state().files == Some(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
let conn = db::open_existing(&f.db.to_string_lossy(), true).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO files (name, parent, size, mtime, type, content_state)
|
||||||
|
VALUES ('ghost.txt', '/nowhere/', 0, 0, 0, 3)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
std::thread::sleep(Duration::from_secs(3));
|
||||||
|
assert_eq!(coord.state().files, Some(2), "an idle tick recounted");
|
||||||
|
coord.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_mode_reports_an_active_watcher() {
|
fn auto_mode_reports_an_active_watcher() {
|
||||||
let f = Fixture::new(true);
|
let f = Fixture::new(true);
|
||||||
|
|
@ -882,6 +944,33 @@ fn an_unreadable_path_is_not_treated_as_deleted() {
|
||||||
std::fs::remove_file(&present).ok();
|
std::fs::remove_file(&present).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only what the watcher registers with restarts it; a theme or search
|
||||||
|
/// setting must leave a large tree's watches alone.
|
||||||
|
#[test]
|
||||||
|
fn only_watch_inputs_restart_the_watcher() {
|
||||||
|
let old = Config::default();
|
||||||
|
let differs = |edit: fn(&mut Config)| {
|
||||||
|
let mut new = old.clone();
|
||||||
|
edit(&mut new);
|
||||||
|
inner::watch_inputs_differ(&old, &new)
|
||||||
|
};
|
||||||
|
assert!(!differs(|_| {}));
|
||||||
|
assert!(!differs(|c| c.search.debounce_ms += 100));
|
||||||
|
assert!(!differs(|c| c.processing.batch_size += 1));
|
||||||
|
assert!(!differs(|c| c.indexing.reindex_interval_minutes += 1));
|
||||||
|
assert!(differs(|c| c
|
||||||
|
.paths
|
||||||
|
.indexing_paths
|
||||||
|
.push("/elsewhere".into())));
|
||||||
|
assert!(differs(
|
||||||
|
|c| c.indexing.include_hidden = !c.indexing.include_hidden
|
||||||
|
));
|
||||||
|
assert!(differs(
|
||||||
|
|c| c.indexing.follow_symlinks = !c.indexing.follow_symlinks
|
||||||
|
));
|
||||||
|
assert!(differs(|c| c.indexing.ignore_patterns.push("*.tmp".into())));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enqueue_last_wins_and_rename_splits() {
|
fn enqueue_last_wins_and_rename_splits() {
|
||||||
let mut pending = HashMap::new();
|
let mut pending = HashMap::new();
|
||||||
|
|
|
||||||
|
|
@ -275,13 +275,11 @@ impl DocDecoder {
|
||||||
const ZSTD_LEVEL: i32 = 3;
|
const ZSTD_LEVEL: i32 = 3;
|
||||||
|
|
||||||
/// Reusable compression context for the `documents_text` sidecar; one per
|
/// Reusable compression context for the `documents_text` sidecar; one per
|
||||||
/// batch. Measured (`benches/index.rs`, group `zstd_encode`).
|
/// indexing run. Measured (`benches/index.rs`, group `zstd_encode`).
|
||||||
pub struct DocEncoder {
|
pub struct DocEncoder {
|
||||||
ctx: zstd::bulk::Compressor<'static>,
|
ctx: zstd::bulk::Compressor<'static>,
|
||||||
/// One row's compressed output, reused. `zstd`'s `WriteBuf` for `Vec`
|
/// The last row's compressed output, reused, so the writer allocates
|
||||||
/// writes from offset 0 and sets the length, so a body cannot be
|
/// nothing per document once it has grown.
|
||||||
/// compressed straight into a shared arena; it lands here and is copied
|
|
||||||
/// across, with no allocation once both buffers have grown.
|
|
||||||
row: Vec<u8>,
|
row: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,14 +298,12 @@ impl DocEncoder {
|
||||||
.map_err(|e| format!("zstd encode: {}", e))
|
.map_err(|e| format!("zstd encode: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append the compressed form of `text` to `arena`, returning where it
|
/// The compressed form of `text`, valid until the next call; `None` for an
|
||||||
/// landed, so a whole batch's bodies share one allocation instead of one
|
/// empty body, which gets no `documents_text` row.
|
||||||
/// `Vec` per document.
|
pub fn body(&mut self, text: &str) -> Result<Option<&[u8]>, String> {
|
||||||
pub fn encode_into(
|
if text.is_empty() {
|
||||||
&mut self,
|
return Ok(None);
|
||||||
text: &str,
|
}
|
||||||
arena: &mut Vec<u8>,
|
|
||||||
) -> Result<std::ops::Range<usize>, String> {
|
|
||||||
self.row.clear();
|
self.row.clear();
|
||||||
// `compress_to_buffer` writes into the buffer's capacity and fails
|
// `compress_to_buffer` writes into the buffer's capacity and fails
|
||||||
// rather than growing it, so the room has to be there first.
|
// rather than growing it, so the room has to be there first.
|
||||||
|
|
@ -315,9 +311,7 @@ impl DocEncoder {
|
||||||
self.ctx
|
self.ctx
|
||||||
.compress_to_buffer(text.as_bytes(), &mut self.row)
|
.compress_to_buffer(text.as_bytes(), &mut self.row)
|
||||||
.map_err(|e| format!("zstd encode: {}", e))?;
|
.map_err(|e| format!("zstd encode: {}", e))?;
|
||||||
let start = arena.len();
|
Ok(Some(&self.row))
|
||||||
arena.extend_from_slice(&self.row);
|
|
||||||
Ok(start..arena.len())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -739,10 +733,16 @@ pub fn rows_in_range_page(
|
||||||
|
|
||||||
/// Reset every failed file to pending and drop the failure records, so the
|
/// Reset every failed file to pending and drop the failure records, so the
|
||||||
/// coming run re-attempts them. Returns how many rows were reset.
|
/// coming run re-attempts them. Returns how many rows were reset.
|
||||||
|
///
|
||||||
|
/// Found through `failed_files`, which holds exactly the failed rows (see
|
||||||
|
/// [`STATE_FAILED`]): no index covers `content_state = 2`, so asking `files`
|
||||||
|
/// scanned the table every run. Measured at 373k rows, 287 failed: 16 →
|
||||||
|
/// 0.3 ms with the table cached.
|
||||||
pub fn retry_failed_files(tx: &Transaction<'_>) -> Result<usize, String> {
|
pub fn retry_failed_files(tx: &Transaction<'_>) -> Result<usize, String> {
|
||||||
let reset = exec(
|
let reset = exec(
|
||||||
tx,
|
tx,
|
||||||
"UPDATE files SET content_state = ?1 WHERE content_state = ?2",
|
"UPDATE files SET content_state = ?1 \
|
||||||
|
WHERE id IN (SELECT file_id FROM failed_files) AND content_state = ?2",
|
||||||
params![STATE_PENDING, STATE_FAILED],
|
params![STATE_PENDING, STATE_FAILED],
|
||||||
|| "reset failed files to pending".to_string(),
|
|| "reset failed files to pending".to_string(),
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -895,9 +895,22 @@ pub fn analyzed_file_count(conn: &Connection) -> Option<i64> {
|
||||||
rows.max()
|
rows.max()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checkpoint, VACUUM, `PRAGMA optimize`, checkpoint; the trailing checkpoint
|
/// Create `idx_files_hash` if it is missing: after a fresh index's first run,
|
||||||
/// empties what VACUUM's copy-back and `optimize` wrote to the log. Returns
|
/// in one sorted build. It is not in [`crate::db::schema::SCHEMA_CURRENT`]:
|
||||||
/// whether it vacuumed.
|
/// a SHA-256 key lands on a random leaf, so kept up through a bulk load, every
|
||||||
|
/// walk commit rewrote hundreds of the index's pages. Measured (`memprobe
|
||||||
|
/// cold`): bytes written 4.77 → 2.87 GB over `/usr` (373k files), 938 → 855
|
||||||
|
/// MiB over the cargo registry (41k), CPU within noise, and about 10 MiB more
|
||||||
|
/// peak for the sort. Only [`crate::search::find_duplicate_groups`] reads it,
|
||||||
|
/// and until the build it sorts the table instead.
|
||||||
|
pub fn build_hash_index(conn: &Connection) -> Result<(), String> {
|
||||||
|
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);")
|
||||||
|
.map_err(|e| format!("build hash index: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checkpoint, [`build_hash_index`], VACUUM, `PRAGMA optimize`, checkpoint;
|
||||||
|
/// the trailing checkpoint empties what VACUUM's copy-back and `optimize`
|
||||||
|
/// wrote to the log. Returns whether it vacuumed.
|
||||||
///
|
///
|
||||||
/// Run on a connection from [`crate::db::open::open_maintenance`], never the
|
/// Run on a connection from [`crate::db::open::open_maintenance`], never the
|
||||||
/// indexer's. VACUUM's temporary database goes in the directory of `db_path`,
|
/// indexer's. VACUUM's temporary database goes in the directory of `db_path`,
|
||||||
|
|
@ -922,6 +935,8 @@ pub fn maintain(conn: &Connection, db_path: &str) -> Result<bool, String> {
|
||||||
#[cfg(feature = "probe")]
|
#[cfg(feature = "probe")]
|
||||||
probe.step("maintain checkpoint");
|
probe.step("maintain checkpoint");
|
||||||
|
|
||||||
|
build_hash_index(conn)?;
|
||||||
|
|
||||||
let page_count = pragma_number(conn, "page_count")?;
|
let page_count = pragma_number(conn, "page_count")?;
|
||||||
let freelist = pragma_number(conn, "freelist_count")?;
|
let freelist = pragma_number(conn, "freelist_count")?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -224,29 +224,18 @@ fn a_compressed_body_carries_its_uncompressed_length() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Several rows into one arena, as the writer does. `zstd`'s `WriteBuf` for
|
/// Several rows through one encoder, as the writer does: each body must come
|
||||||
/// `Vec` writes from offset zero and sets the length, so compressing straight
|
/// back byte-identical from the slice handed out for it, whatever the size
|
||||||
/// into a shared arena would overwrite the previous row. Each body must come
|
/// of the rows before it, and an empty body gets none.
|
||||||
/// back byte-identical out of its own range.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_arena_keeps_every_row_it_is_given() {
|
fn one_encoder_serves_every_row_it_is_given() {
|
||||||
let mut enc = DocEncoder::new().unwrap();
|
let mut enc = DocEncoder::new().unwrap();
|
||||||
let bodies = [
|
let long = "a considerably longer second document ".repeat(512);
|
||||||
"the first document",
|
for text in ["the first document", "", long.as_str(), "third"] {
|
||||||
"",
|
let Some(blob) = enc.body(text).unwrap() else {
|
||||||
"a considerably longer second document ".repeat(512).as_str(),
|
assert!(text.is_empty(), "only an empty body has no blob");
|
||||||
"third",
|
continue;
|
||||||
]
|
};
|
||||||
.map(str::to_string);
|
|
||||||
|
|
||||||
let mut arena = Vec::new();
|
|
||||||
let mut ranges = Vec::new();
|
|
||||||
for text in &bodies {
|
|
||||||
ranges.push(enc.encode_into(text, &mut arena).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (text, at) in bodies.iter().zip(&ranges) {
|
|
||||||
let blob = &arena[at.clone()];
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
raw_text_len(blob),
|
raw_text_len(blob),
|
||||||
Some(text.len() as u64),
|
Some(text.len() as u64),
|
||||||
|
|
@ -254,28 +243,10 @@ fn an_arena_keeps_every_row_it_is_given() {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
DocDecoder::new().unwrap().decode(blob),
|
DocDecoder::new().unwrap().decode(blob),
|
||||||
Some(text.as_str()),
|
Some(text),
|
||||||
"a row did not survive sharing the arena"
|
"a row did not survive the reused buffer"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ranges tile the arena in order: a gap or an overlap means one row
|
|
||||||
// landed on another.
|
|
||||||
let mut next = 0;
|
|
||||||
for at in &ranges {
|
|
||||||
assert_eq!(at.start, next, "rows must be contiguous");
|
|
||||||
next = at.end;
|
|
||||||
}
|
|
||||||
assert_eq!(next, arena.len(), "the arena holds exactly the four bodies");
|
|
||||||
|
|
||||||
// Reuse: a second batch must not read the first one's bytes.
|
|
||||||
arena.clear();
|
|
||||||
let at = enc.encode_into("a fresh batch", &mut arena).unwrap();
|
|
||||||
assert_eq!(at.start, 0);
|
|
||||||
assert_eq!(
|
|
||||||
DocDecoder::new().unwrap().decode(&arena[at]),
|
|
||||||
Some("a fresh batch")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,9 @@ pub const PRAGMAS_WALK_READER: &str = "
|
||||||
/// filename query already does: `searchtime` at 300k rows, 25 ms with or
|
/// filename query already does: `searchtime` at 300k rows, 25 ms with or
|
||||||
/// without a filter, where the indexes had made a one-day filter 0.06 ms.
|
/// without a filter, where the indexes had made a one-day filter 0.06 ms.
|
||||||
///
|
///
|
||||||
|
/// `idx_files_hash` is left out on purpose and built after the first run; see
|
||||||
|
/// [`crate::db::repo::build_hash_index`].
|
||||||
|
///
|
||||||
/// `documents_text` holds a row only when the extractor produced text (an
|
/// `documents_text` holds a row only when the extractor produced text (an
|
||||||
/// audio file with empty tags is done but has none), so `files.content_state`
|
/// audio file with empty tags is done but has none), so `files.content_state`
|
||||||
/// is the authority on "content indexed". The uncompressed length is not
|
/// is the authority on "content indexed". The uncompressed length is not
|
||||||
|
|
@ -203,7 +206,6 @@ CREATE TABLE files (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_files_parent ON files(parent, name);
|
CREATE UNIQUE INDEX idx_files_parent ON files(parent, name);
|
||||||
CREATE INDEX idx_files_hash ON files(hash);
|
|
||||||
CREATE INDEX idx_files_content_pending ON files(id) WHERE content_state = 0;
|
CREATE INDEX idx_files_content_pending ON files(id) WHERE content_state = 0;
|
||||||
|
|
||||||
CREATE TABLE failed_files (
|
CREATE TABLE failed_files (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use lofty::{
|
use lofty::{
|
||||||
|
config::ParseOptions,
|
||||||
file::TaggedFileExt,
|
file::TaggedFileExt,
|
||||||
probe::Probe,
|
probe::Probe,
|
||||||
tag::{Accessor, ItemKey},
|
tag::{Accessor, ItemKey},
|
||||||
|
|
@ -24,8 +25,14 @@ impl Extractor for AudioExtractor {
|
||||||
out: &mut String,
|
out: &mut String,
|
||||||
_scratch: &mut Scratch,
|
_scratch: &mut Scratch,
|
||||||
) -> Result<(), ExtractError> {
|
) -> Result<(), ExtractError> {
|
||||||
|
// Tags only: the audio properties (duration, bitrate) cost a seek to
|
||||||
|
// the end and, in an MP3 without a Xing header, a frame-sync search
|
||||||
|
// back from it, and fail a file whose audio is cut short. Measured
|
||||||
|
// (`extractprobe`, 18 generated 3-minute MP3s and 40 Ogg sounds):
|
||||||
|
// `lseek` + `read` calls 2,567 → 790, cached throughput 1.4–2.3x.
|
||||||
let tagged = Probe::open(path)
|
let tagged = Probe::open(path)
|
||||||
.map_err(|e| format!("lofty probe {}: {}", path.display(), e))?
|
.map_err(|e| format!("lofty probe {}: {}", path.display(), e))?
|
||||||
|
.options(ParseOptions::new().read_properties(false))
|
||||||
.read()
|
.read()
|
||||||
.map_err(|e| format!("lofty read {}: {}", path.display(), e))?;
|
.map_err(|e| format!("lofty read {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
|
|
@ -125,6 +132,17 @@ mod tests {
|
||||||
assert_eq!(out, "Blue Monday New Order Power Corruption Synthpop");
|
assert_eq!(out, "Blue Monday New Order Power Corruption Synthpop");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A download cut short keeps its tag: the audio after it is never read.
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_stream_still_yields_its_tags() {
|
||||||
|
let path = write_mp3("audio-truncated", &[("TIT2", "Half Written")]);
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
// The tag, the first frame's header and a few bytes of its body.
|
||||||
|
let tag_end = bytes.len() - 4 * 417;
|
||||||
|
std::fs::write(&path, &bytes[..tag_end + 10]).unwrap();
|
||||||
|
assert_eq!(extract(&path).expect("extract"), "Half Written");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_untagged_file_yields_empty_text() {
|
fn an_untagged_file_yields_empty_text() {
|
||||||
let path = write_mp3("audio-untagged", &[]);
|
let path = write_mp3("audio-untagged", &[]);
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,10 @@ impl Limits {
|
||||||
pub struct Scratch {
|
pub struct Scratch {
|
||||||
/// The head bytes a walk worker hashes and sniffs each file from.
|
/// The head bytes a walk worker hashes and sniffs each file from.
|
||||||
head: Vec<u8>,
|
head: Vec<u8>,
|
||||||
/// One container member or one whole file, for a parser that will not
|
/// One whole file, for a parser that will not take a reader. Grows to
|
||||||
/// take a reader. Grows to the largest member the worker has met and
|
/// the largest file the worker has met and stays there.
|
||||||
/// stays there, bounded by [`Limits::inflate`].
|
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
/// quick-xml's per-event buffer. Separate from `bytes` because a
|
/// quick-xml's per-event buffer.
|
||||||
/// container walk holds both at once.
|
|
||||||
events: Vec<u8>,
|
events: Vec<u8>,
|
||||||
/// XLSX's shared-string table. Cleared between files; the entries keep
|
/// XLSX's shared-string table. Cleared between files; the entries keep
|
||||||
/// their capacity, which is most of what a table costs.
|
/// their capacity, which is most of what a table costs.
|
||||||
|
|
@ -110,24 +108,22 @@ impl Scratch {
|
||||||
&self.head
|
&self.head
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The raw-bytes buffer: one container member, one OLE stream, or one
|
/// The raw-bytes buffer: one whole file for a format whose parser will
|
||||||
/// whole file for a format whose parser will not take a reader. The
|
/// not take a reader. The caller clears it before filling.
|
||||||
/// caller clears it before filling.
|
|
||||||
pub(crate) fn bytes_mut(&mut self) -> &mut Vec<u8> {
|
pub(crate) fn bytes_mut(&mut self) -> &mut Vec<u8> {
|
||||||
&mut self.bytes
|
&mut self.bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The member and event buffers together, which a container walk holds
|
/// quick-xml's event buffer, for a container's XML members.
|
||||||
/// at once; two `&mut self` accessors could not lend both.
|
pub(crate) fn events(&mut self) -> &mut Vec<u8> {
|
||||||
pub(crate) fn container_bufs(&mut self) -> (&mut Vec<u8>, &mut Vec<u8>) {
|
&mut self.events
|
||||||
(&mut self.bytes, &mut self.events)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same pair plus the shared-string table, cleared, which XLSX needs
|
/// The event buffer plus the shared-string table, cleared, which XLSX
|
||||||
/// all at once.
|
/// needs at once; two `&mut self` accessors could not lend both.
|
||||||
pub(crate) fn xlsx_bufs(&mut self) -> (&mut Vec<u8>, &mut Vec<u8>, &mut Vec<String>) {
|
pub(crate) fn xlsx_bufs(&mut self) -> (&mut Vec<u8>, &mut Vec<String>) {
|
||||||
self.strings.clear();
|
self.strings.clear();
|
||||||
(&mut self.bytes, &mut self.events, &mut self.strings)
|
(&mut self.events, &mut self.strings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,59 +218,53 @@ fn open_container(path: &Path) -> Result<Archive, Box<dyn Error>> {
|
||||||
Ok(ZipArchive::new(BufReader::new(File::open(path)?))?)
|
Ok(ZipArchive::new(BufReader::new(File::open(path)?))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inflate one whole member into `buf`, the worker's buffer, reused across
|
/// One member, inflated as the parser reads it, bounded by `limit`
|
||||||
/// members and files so members cost no allocation after the first document.
|
/// ([`super::Limits::inflate`]): inflation stops where parsing does, at the
|
||||||
/// Bounded by `limit` ([`super::Limits::inflate`]); the buffer never shrinks,
|
/// text limit, and nothing holds a whole member. Measured against inflating
|
||||||
/// so one hostile document leaves the worker holding up to `limit` for the
|
/// each member into a per-worker buffer that kept its largest size
|
||||||
/// rest of the pass.
|
/// (`extractprobe`, 1.25 MB `document.xml` members): peak live per worker
|
||||||
fn member_bytes<R: Read + Seek>(
|
/// 2.3 → 0.4 MiB, VmHWM 20.5 → 14.4 MiB, throughput unchanged.
|
||||||
archive: &mut ZipArchive<R>,
|
fn member<'a, R: Read + Seek>(
|
||||||
|
archive: &'a mut ZipArchive<R>,
|
||||||
name: &str,
|
name: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
buf: &mut Vec<u8>,
|
) -> Result<impl BufRead + 'a, Box<dyn Error>> {
|
||||||
) -> Result<(), Box<dyn Error>> {
|
Ok(BufReader::new(archive.by_name(name)?.take(limit as u64)))
|
||||||
buf.clear();
|
|
||||||
archive
|
|
||||||
.by_name(name)?
|
|
||||||
.take(limit as u64)
|
|
||||||
.read_to_end(buf)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Names of the `.xml` members under `prefix`, in archive order, not
|
/// Names of the `.xml` members under `prefix`, in number order: shorter
|
||||||
/// `file_names()`, which iterates a hash map and would shuffle the slides of
|
/// first, so `slide2.xml` comes before `slide10.xml`. From the central
|
||||||
/// every presentation.
|
/// directory the archive already holds; naming members through `by_index`
|
||||||
fn xml_members_under<R: Read + Seek>(
|
/// seeks to and reads each one's local header, about 3 `lseek` and 2 reads a
|
||||||
archive: &mut ZipArchive<R>,
|
/// member, twice for a deck. Measured (`extractprobe`, 20 copies of a real
|
||||||
prefix: &str,
|
/// 86-member deck plus 4 other documents): `lseek` + `read` calls 22,289 →
|
||||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
/// 4,444, throughput 1,800 → 2,330 files/s.
|
||||||
let mut names = Vec::new();
|
fn xml_members_under<R: Read + Seek>(archive: &ZipArchive<R>, prefix: &str) -> Vec<String> {
|
||||||
for i in 0..archive.len() {
|
let mut names: Vec<String> = archive
|
||||||
let name = archive.by_index(i)?.name().to_string();
|
.file_names()
|
||||||
if name.starts_with(prefix) && name.ends_with(".xml") {
|
.filter(|name| name.starts_with(prefix) && name.ends_with(".xml"))
|
||||||
names.push(name);
|
.map(str::to_string)
|
||||||
}
|
.collect();
|
||||||
}
|
names.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
|
||||||
Ok(names)
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A format whose whole text lives in one member under one spec.
|
/// A format whose whole text lives in one member under one spec.
|
||||||
fn single_member(
|
fn single_member(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
member: &str,
|
name: &str,
|
||||||
spec: &TextSpec,
|
spec: &TextSpec,
|
||||||
out: &mut String,
|
out: &mut String,
|
||||||
scratch: &mut Scratch,
|
scratch: &mut Scratch,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let limits = scratch.limits();
|
let limits = scratch.limits();
|
||||||
let mut archive = open_container(path)?;
|
let mut archive = open_container(path)?;
|
||||||
let (bytes, events) = scratch.container_bufs();
|
let xml = member(&mut archive, name, limits.inflate)?;
|
||||||
member_bytes(&mut archive, member, limits.inflate, bytes)?;
|
collect_xml_text(xml, spec, out, limits.text, scratch.events())
|
||||||
collect_xml_text(bytes.as_slice(), spec, out, limits.text, events)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concatenate what `collect` gets out of each `.xml` member under `prefix`,
|
/// Concatenate what `collect` gets out of each `.xml` member under `prefix`,
|
||||||
/// in archive order, stopping once the text reaches [`super::Limits::text`],
|
/// in number order, stopping once the text reaches [`super::Limits::text`],
|
||||||
/// the point past which the caller would discard it anyway.
|
/// the point past which the caller would discard it anyway.
|
||||||
fn collect_members(
|
fn collect_members(
|
||||||
archive: &mut Archive,
|
archive: &mut Archive,
|
||||||
|
|
@ -279,7 +273,7 @@ fn collect_members(
|
||||||
limit: usize,
|
limit: usize,
|
||||||
mut collect: impl FnMut(&mut Archive, &str, &mut String) -> Result<(), Box<dyn Error>>,
|
mut collect: impl FnMut(&mut Archive, &str, &mut String) -> Result<(), Box<dyn Error>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
for name in xml_members_under(archive, prefix)? {
|
for name in xml_members_under(archive, prefix) {
|
||||||
if out.len() >= limit {
|
if out.len() >= limit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -300,15 +294,15 @@ fn collect_members(
|
||||||
fn extract_pptx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<(), Box<dyn Error>> {
|
fn extract_pptx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<(), Box<dyn Error>> {
|
||||||
let limits = scratch.limits();
|
let limits = scratch.limits();
|
||||||
let mut archive = open_container(path)?;
|
let mut archive = open_container(path)?;
|
||||||
let (bytes, events) = scratch.container_bufs();
|
let events = scratch.events();
|
||||||
collect_members(
|
collect_members(
|
||||||
&mut archive,
|
&mut archive,
|
||||||
"ppt/slides/slide",
|
"ppt/slides/slide",
|
||||||
out,
|
out,
|
||||||
limits.text,
|
limits.text,
|
||||||
|archive, name, out| {
|
|archive, name, out| {
|
||||||
member_bytes(archive, name, limits.inflate, bytes)?;
|
let xml = member(archive, name, limits.inflate)?;
|
||||||
collect_xml_text(bytes.as_slice(), &PPTX, out, limits.text, events)?;
|
collect_xml_text(xml, &PPTX, out, limits.text, events)?;
|
||||||
out.push_str("\n--- New Slide ---\n");
|
out.push_str("\n--- New Slide ---\n");
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
|
|
@ -321,9 +315,9 @@ fn extract_pptx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<
|
||||||
out,
|
out,
|
||||||
limits.text,
|
limits.text,
|
||||||
|archive, name, out| {
|
|archive, name, out| {
|
||||||
member_bytes(archive, name, limits.inflate, bytes)?;
|
let xml = member(archive, name, limits.inflate)?;
|
||||||
out.push_str("\n--- Speaker Notes ---\n");
|
out.push_str("\n--- Speaker Notes ---\n");
|
||||||
collect_xml_text(bytes.as_slice(), &PPTX, out, limits.text, events)
|
collect_xml_text(xml, &PPTX, out, limits.text, events)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -338,14 +332,13 @@ fn extract_pptx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<
|
||||||
fn shared_strings<R: Read + Seek>(
|
fn shared_strings<R: Read + Seek>(
|
||||||
archive: &mut ZipArchive<R>,
|
archive: &mut ZipArchive<R>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
bytes: &mut Vec<u8>,
|
|
||||||
events: &mut Vec<u8>,
|
events: &mut Vec<u8>,
|
||||||
strings: &mut Vec<String>,
|
strings: &mut Vec<String>,
|
||||||
) {
|
) {
|
||||||
if member_bytes(archive, "xl/sharedStrings.xml", limit, bytes).is_err() {
|
let Ok(xml) = member(archive, "xl/sharedStrings.xml", limit) else {
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
let mut reader = Reader::from_reader(bytes.as_slice());
|
let mut reader = Reader::from_reader(xml);
|
||||||
// No `trim_text`; see `collect_xml_text`.
|
// No `trim_text`; see `collect_xml_text`.
|
||||||
events.clear();
|
events.clear();
|
||||||
let mut in_text = false;
|
let mut in_text = false;
|
||||||
|
|
@ -481,18 +474,18 @@ fn collect_sheet<R: BufRead>(
|
||||||
fn extract_xlsx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<(), Box<dyn Error>> {
|
fn extract_xlsx(path: &Path, out: &mut String, scratch: &mut Scratch) -> Result<(), Box<dyn Error>> {
|
||||||
let limits = scratch.limits();
|
let limits = scratch.limits();
|
||||||
let mut archive = open_container(path)?;
|
let mut archive = open_container(path)?;
|
||||||
// All three at once: the sheets are read while the table is live, and
|
// Both at once: the sheets are read while the table is live, and
|
||||||
// separate `&mut scratch` borrows cannot overlap.
|
// separate `&mut scratch` borrows cannot overlap.
|
||||||
let (bytes, events, strings) = scratch.xlsx_bufs();
|
let (events, strings) = scratch.xlsx_bufs();
|
||||||
shared_strings(&mut archive, limits.inflate, bytes, events, strings);
|
shared_strings(&mut archive, limits.inflate, events, strings);
|
||||||
collect_members(
|
collect_members(
|
||||||
&mut archive,
|
&mut archive,
|
||||||
"xl/worksheets/sheet",
|
"xl/worksheets/sheet",
|
||||||
out,
|
out,
|
||||||
limits.text,
|
limits.text,
|
||||||
|archive, name, out| {
|
|archive, name, out| {
|
||||||
member_bytes(archive, name, limits.inflate, bytes)?;
|
let xml = member(archive, name, limits.inflate)?;
|
||||||
collect_sheet(bytes.as_slice(), strings, out, limits.text, events)
|
collect_sheet(xml, strings, out, limits.text, events)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -796,7 +789,7 @@ mod tests {
|
||||||
</sheetData></worksheet>";
|
</sheetData></worksheet>";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pptx_marks_each_slide_and_keeps_archive_order() {
|
fn pptx_marks_each_slide() {
|
||||||
let p = container(
|
let p = container(
|
||||||
"pptx",
|
"pptx",
|
||||||
"pptx",
|
"pptx",
|
||||||
|
|
@ -811,6 +804,36 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slides come out in number order whatever order the archive stores
|
||||||
|
/// them in, and `slide10` is not read between `slide1` and `slide2`.
|
||||||
|
#[test]
|
||||||
|
fn pptx_slides_come_out_in_number_order() {
|
||||||
|
let slide = |text: &str| {
|
||||||
|
format!(
|
||||||
|
"<p:sld><p:cSld><p:spTree><p:sp><p:txBody>\
|
||||||
|
<a:p><a:r><a:t>{text}</a:t></a:r></a:p>\
|
||||||
|
</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let (s10, s2, s1) = (slide("ten"), slide("two"), slide("one"));
|
||||||
|
let p = container(
|
||||||
|
"pptx-order",
|
||||||
|
"pptx",
|
||||||
|
&[
|
||||||
|
("ppt/slides/slide10.xml", s10.as_str()),
|
||||||
|
("ppt/slides/slide2.xml", s2.as_str()),
|
||||||
|
("ppt/slides/slide1.xml", s1.as_str()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let text = extract_document_text(&p, "pptx").unwrap();
|
||||||
|
let slides: Vec<&str> = text
|
||||||
|
.split("\n--- New Slide ---\n")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(slides, ["one", "two", "ten"]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Speaker notes live in parts of their own that the slide text never
|
/// Speaker notes live in parts of their own that the slide text never
|
||||||
/// mentions.
|
/// mentions.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ pub struct PlaintextExtractor;
|
||||||
|
|
||||||
/// Read `size` bytes from `f`, never more than `cap`. A short read is not an
|
/// Read `size` bytes from `f`, never more than `cap`. A short read is not an
|
||||||
/// error: a file that shrank keeps its prefix.
|
/// error: a file that shrank keeps its prefix.
|
||||||
|
///
|
||||||
|
/// Zero-filled, and still the faster shape: `take(size).read_to_end` into a
|
||||||
|
/// reserved `Vec` skips the fill but starts at 8 KiB reads and doubles, so
|
||||||
|
/// over `/usr/include` it made 18,431 `read` calls against 15,419 here and
|
||||||
|
/// ran 8% slower (`extractprobe`). Measured and rejected.
|
||||||
fn read_sized(f: &mut File, size: usize, cap: usize, path: &Path) -> Result<Vec<u8>, ExtractError> {
|
fn read_sized(f: &mut File, size: usize, cap: usize, path: &Path) -> Result<Vec<u8>, ExtractError> {
|
||||||
let size = size.min(cap);
|
let size = size.min(cap);
|
||||||
let mut buf = vec![0u8; size];
|
let mut buf = vec![0u8; size];
|
||||||
|
|
|
||||||
|
|
@ -10,112 +10,53 @@ use super::*;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::repo;
|
use crate::db::repo;
|
||||||
|
|
||||||
/// One writer's compressed sidecars: a batch's blobs end to end in `arena`,
|
/// Write one batch of prepared records in one transaction through
|
||||||
/// with `slots[i]` saying where row `i`'s is, that it has none, or that its
|
/// `write_row`, storing any inline text on the row, which both row writers
|
||||||
/// compression failed (kept per row rather than failing the batch).
|
/// leave pending. `enc` is the run's one encoder (see [`store_extracted`]).
|
||||||
///
|
///
|
||||||
/// One per indexing run, reused by every batch and turn the writer takes: the
|
/// Writing an inline row as done outright would save the claim's `UPDATE`,
|
||||||
/// encoder because a zstd context per chunk is wasted CPU (`benches/index.rs`,
|
/// but it would be a second way out of pending beside
|
||||||
/// `zstd_encode`), the arena so the writer allocates nothing per document.
|
/// [`repo::complete_pending`], for 0.5–1.1 µs a row: about 0.1% of a cold
|
||||||
/// Measured against one per writer turn (`indexprobe cold`, 10k docs of
|
/// run's CPU, below `memprobe`'s noise. Measured and rejected.
|
||||||
/// 9–20 KiB): VmHWM 60–65 → 44–50 MiB, CPU ~2% lower.
|
|
||||||
pub struct Bodies {
|
|
||||||
enc: repo::DocEncoder,
|
|
||||||
arena: Vec<u8>,
|
|
||||||
slots: Vec<Result<Option<std::ops::Range<usize>>, String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bodies {
|
|
||||||
pub fn new() -> Result<Bodies, String> {
|
|
||||||
Ok(Bodies {
|
|
||||||
enc: repo::DocEncoder::new()?,
|
|
||||||
arena: Vec::new(),
|
|
||||||
slots: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compress one chunk's bodies before the caller takes the connection, so
|
|
||||||
/// the lock covers only the SQL.
|
|
||||||
fn fill<'a>(&mut self, texts: impl Iterator<Item = Option<&'a str>>) {
|
|
||||||
self.arena.clear();
|
|
||||||
self.slots.clear();
|
|
||||||
for text in texts {
|
|
||||||
let slot = match text {
|
|
||||||
Some(t) if !t.is_empty() => self.enc.encode_into(t, &mut self.arena).map(Some),
|
|
||||||
_ => Ok(None),
|
|
||||||
};
|
|
||||||
self.slots.push(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Row `i`'s blob, or why there is none.
|
|
||||||
fn get(&self, i: usize) -> Result<Option<&[u8]>, &str> {
|
|
||||||
match &self.slots[i] {
|
|
||||||
Ok(Some(at)) => Ok(Some(&self.arena[at.clone()])),
|
|
||||||
Ok(None) => Ok(None),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The skeleton behind [`process_batch_updates`] and
|
|
||||||
/// [`process_batch_inserts`]: compress each chunk's bodies outside the lock,
|
|
||||||
/// write its rows in one transaction through `write_row`, and store any
|
|
||||||
/// inline text on the row, which both row writers leave pending.
|
|
||||||
fn write_prepared_records(
|
fn write_prepared_records(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
records: &[OwnedNewFile],
|
records: &[OwnedNewFile],
|
||||||
stop_flag: &Arc<AtomicBool>,
|
stop_flag: &Arc<AtomicBool>,
|
||||||
config: &Config,
|
enc: &mut repo::DocEncoder,
|
||||||
bodies: &mut Bodies,
|
|
||||||
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>,
|
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// `.max(1)`: `chunks(0)` panics on the indexing thread, so a hand-edited
|
let conn = crate::lock_ok(conn_mutex);
|
||||||
// `batch_size = 0` would wedge indexing while the UI reads "Running".
|
let tx = conn
|
||||||
let chunk_size = config.processing.batch_size.max(1);
|
.unchecked_transaction()
|
||||||
for batch in records.chunks(chunk_size) {
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||||
|
|
||||||
|
for rec in records {
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let id = write_row(&tx, rec)?;
|
||||||
// Outside the lock; see `Bodies::fill`.
|
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
||||||
bodies.fill(batch.iter().map(|r| r.inline_text.as_deref()));
|
match enc.body(text) {
|
||||||
let conn = crate::lock_ok(conn_mutex);
|
Ok(zstd) => {
|
||||||
let tx = conn
|
repo::store_pending_text(&tx, id, text, zstd)?;
|
||||||
.unchecked_transaction()
|
|
||||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
|
||||||
|
|
||||||
for (i, rec) in batch.iter().enumerate() {
|
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let id = write_row(&tx, rec)?;
|
|
||||||
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
|
||||||
match bodies.get(i) {
|
|
||||||
Ok(zstd) => {
|
|
||||||
repo::store_pending_text(&tx, id, text, zstd)?;
|
|
||||||
}
|
|
||||||
Err(e) => crate::log_warn!("compress text for {}: {}", rec.path(), e),
|
|
||||||
}
|
}
|
||||||
|
Err(e) => crate::log_warn!("compress text for {}: {}", rec.path(), e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
tx.commit()
|
||||||
|
.map_err(|e| format!("Failed to commit transaction: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write already-prepared records for files whose content changed. No
|
/// Write one batch of already-prepared records for files whose content
|
||||||
/// filesystem I/O; records carrying inline text are stored complete here,
|
/// changed. No filesystem I/O; records carrying inline text are stored
|
||||||
/// the rest stay pending.
|
/// complete here, the rest stay pending.
|
||||||
pub fn process_batch_updates(
|
pub fn process_batch_updates(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
files_to_update: &[OwnedNewFile],
|
files_to_update: &[OwnedNewFile],
|
||||||
stop_flag: &Arc<AtomicBool>,
|
stop_flag: &Arc<AtomicBool>,
|
||||||
config: &Config,
|
enc: &mut repo::DocEncoder,
|
||||||
bodies: &mut Bodies,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if files_to_update.is_empty() {
|
if files_to_update.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -124,45 +65,37 @@ pub fn process_batch_updates(
|
||||||
// The row leaves this closure pending whenever it carries inline text:
|
// The row leaves this closure pending whenever it carries inline text:
|
||||||
// `update_file_basic` cleared a DONE row's content and reset its state
|
// `update_file_basic` cleared a DONE row's content and reset its state
|
||||||
// in this same transaction, and the insert fallback created it pending.
|
// in this same transaction, and the insert fallback created it pending.
|
||||||
write_prepared_records(
|
write_prepared_records(conn_mutex, files_to_update, stop_flag, enc, |tx, rec| {
|
||||||
conn_mutex,
|
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
|
||||||
files_to_update,
|
format!(
|
||||||
stop_flag,
|
"Failed to update file record + clear stale content for {}: {}",
|
||||||
config,
|
rec.path(),
|
||||||
bodies,
|
e
|
||||||
|tx, rec| {
|
)
|
||||||
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
|
})?;
|
||||||
format!(
|
|
||||||
"Failed to update file record + clear stale content for {}: {}",
|
|
||||||
rec.path(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// No row matched: dropping the update would leave the mtime stale
|
// No row matched: dropping the update would leave the mtime stale
|
||||||
// and the file re-hashed on every run forever.
|
// and the file re-hashed on every run forever.
|
||||||
match updated {
|
match updated {
|
||||||
None => {
|
None => {
|
||||||
crate::log_warn!(
|
crate::log_warn!(
|
||||||
"no indexed row matched {} during update; inserting instead",
|
"no indexed row matched {} during update; inserting instead",
|
||||||
rec.path()
|
rec.path()
|
||||||
);
|
);
|
||||||
repo::insert_file(tx, &rec.as_new_file())
|
repo::insert_file(tx, &rec.as_new_file())
|
||||||
.map_err(|e| format!("Failed to insert file record: {}", e))
|
.map_err(|e| format!("Failed to insert file record: {}", e))
|
||||||
}
|
|
||||||
some => Ok(some),
|
|
||||||
}
|
}
|
||||||
},
|
some => Ok(some),
|
||||||
)
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write already-prepared records for newly discovered files.
|
/// Write one batch of already-prepared records for newly discovered files.
|
||||||
pub fn process_batch_inserts(
|
pub fn process_batch_inserts(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
files_to_insert: &[OwnedNewFile],
|
files_to_insert: &[OwnedNewFile],
|
||||||
stop_flag: &Arc<AtomicBool>,
|
stop_flag: &Arc<AtomicBool>,
|
||||||
config: &Config,
|
enc: &mut repo::DocEncoder,
|
||||||
bodies: &mut Bodies,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if files_to_insert.is_empty() {
|
if files_to_insert.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -170,17 +103,10 @@ pub fn process_batch_inserts(
|
||||||
|
|
||||||
// `insert_file` returned `Some` only by creating the row, pending when it
|
// `insert_file` returned `Some` only by creating the row, pending when it
|
||||||
// needs content, so it cannot carry content from anywhere.
|
// needs content, so it cannot carry content from anywhere.
|
||||||
write_prepared_records(
|
write_prepared_records(conn_mutex, files_to_insert, stop_flag, enc, |tx, rec| {
|
||||||
conn_mutex,
|
repo::insert_file(tx, &rec.as_new_file())
|
||||||
files_to_insert,
|
.map_err(|e| format!("Failed to insert file record: {}", e))
|
||||||
stop_flag,
|
})
|
||||||
config,
|
|
||||||
bodies,
|
|
||||||
|tx, rec| {
|
|
||||||
repo::insert_file(tx, &rec.as_new_file())
|
|
||||||
.map_err(|e| format!("Failed to insert file record: {}", e))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the rows a completed run found no file behind; returns how many
|
/// Delete the rows a completed run found no file behind; returns how many
|
||||||
|
|
@ -200,6 +126,10 @@ pub fn process_batch_inserts(
|
||||||
/// Extraction may still be running (its writes serialize on this connection
|
/// Extraction may still be running (its writes serialize on this connection
|
||||||
/// mutex), so between chunks a doomed row can flip PENDING→DONE; resolved any
|
/// mutex), so between chunks a doomed row can flip PENDING→DONE; resolved any
|
||||||
/// earlier, its posting would outlive the row.
|
/// earlier, its posting would outlive the row.
|
||||||
|
///
|
||||||
|
/// Not worth folding the lookup into the delete (`DELETE … RETURNING` per
|
||||||
|
/// path): the lookups are 35–52 ms of 1.4–1.5 s over the 40k paths above,
|
||||||
|
/// where the commit, FTS5 writing its tombstones, is 1.0–1.2 s.
|
||||||
pub fn cleanup_stale_index_entries(
|
pub fn cleanup_stale_index_entries(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
stale_paths: &[String],
|
stale_paths: &[String],
|
||||||
|
|
@ -357,11 +287,6 @@ pub fn count_extract_scope(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows per compression chunk and per transaction inside [`store_extracted`]:
|
|
||||||
/// half of what a writer turn may hand in (`pipeline::READY_TOPUP` is 64), so
|
|
||||||
/// a full turn commits twice and each hold of the connection stays short.
|
|
||||||
const STORE_CHUNK: usize = 32;
|
|
||||||
|
|
||||||
/// What one [`store_extracted`] call did with the rows it was handed.
|
/// What one [`store_extracted`] call did with the rows it was handed.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub struct Stored {
|
pub struct Stored {
|
||||||
|
|
@ -373,57 +298,56 @@ pub struct Stored {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write already-extracted rows, all that runs with the connection held,
|
/// Write already-extracted rows, all that runs with the connection held,
|
||||||
/// until `deadline`. FTS5 tokenization happens here: the writer thread's
|
/// until `deadline`, in one transaction. FTS5 tokenization happens here: the
|
||||||
/// dominant cost. A turn overruns the deadline by at most one
|
/// writer thread's dominant cost. A turn overruns the deadline by at most one
|
||||||
/// document; at least one row is always consumed unless the run is stopped,
|
/// document; at least one row is always consumed unless the run is stopped,
|
||||||
/// so a caller looping on this cannot spin. A row whose write fails is
|
/// so a caller looping on this cannot spin. A row whose write fails is
|
||||||
/// logged and consumed; its `content_state` stays pending for the next run.
|
/// logged and consumed; its `content_state` stays pending for the next run.
|
||||||
|
///
|
||||||
|
/// `enc` is the run's one encoder, compressing each body as its row is
|
||||||
|
/// written: a zstd context per call is wasted CPU (`benches/index.rs`,
|
||||||
|
/// `zstd_encode`), and nothing else takes the connection during a run but
|
||||||
|
/// `stop_indexing`'s checkpoint, so compressing outside the lock bought
|
||||||
|
/// nothing. Measured against compressing each 32-row chunk into an arena
|
||||||
|
/// first and committing per chunk (`memprobe cold`): bytes written 855 → 825
|
||||||
|
/// MiB on the cargo registry (41k files) and 2.88 → 2.81 GB on `/usr` (373k),
|
||||||
|
/// peak RSS 180–200 → 174–176 MiB, CPU within noise.
|
||||||
pub fn store_extracted(
|
pub fn store_extracted(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
rows: &[crate::content::ExtractedRow],
|
rows: &[crate::content::ExtractedRow],
|
||||||
stop_flag: &Arc<AtomicBool>,
|
stop_flag: &Arc<AtomicBool>,
|
||||||
deadline: std::time::Instant,
|
deadline: std::time::Instant,
|
||||||
bodies: &mut Bodies,
|
enc: &mut repo::DocEncoder,
|
||||||
) -> Result<Stored, String> {
|
) -> Result<Stored, String> {
|
||||||
let mut done = Stored::default();
|
let mut done = Stored::default();
|
||||||
for chunk in rows.chunks(STORE_CHUNK) {
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
return Ok(done);
|
||||||
break;
|
}
|
||||||
}
|
let conn = crate::lock_ok(conn_mutex);
|
||||||
// Outside the lock; see `Bodies::fill`.
|
let tx = conn
|
||||||
bodies.fill(
|
.unchecked_transaction()
|
||||||
chunk
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||||
.iter()
|
for row in rows {
|
||||||
.map(|r| crate::file_handling::outcome_body(&r.outcome)),
|
// Counted before anything can skip it: a failed row still leaves.
|
||||||
);
|
done.consumed += 1;
|
||||||
let conn = crate::lock_ok(conn_mutex);
|
let body = crate::file_handling::outcome_body(&row.outcome).map(|text| enc.body(text));
|
||||||
let tx = conn
|
match body.transpose() {
|
||||||
.unchecked_transaction()
|
Err(e) => crate::log_warn!("compress text for {}: {}", row.path(), e),
|
||||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
Ok(zstd) => {
|
||||||
let mut cut = false;
|
match repo::complete_pending(&tx, row.file_id, &row.outcome, zstd.flatten()) {
|
||||||
for (i, row) in chunk.iter().enumerate() {
|
|
||||||
// Counted before anything can skip it: a failed row still leaves.
|
|
||||||
done.consumed += 1;
|
|
||||||
match bodies.get(i) {
|
|
||||||
Err(e) => crate::log_warn!("compress text for {}: {}", row.path(), e),
|
|
||||||
Ok(zstd) => match repo::complete_pending(&tx, row.file_id, &row.outcome, zstd) {
|
|
||||||
Ok(true) => done.written += 1,
|
Ok(true) => done.written += 1,
|
||||||
// No longer pending: stored or deleted since it was paged.
|
// No longer pending: stored or deleted since it was paged.
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => crate::log_warn!("content indexing for {}: {}", row.path(), e),
|
Err(e) => crate::log_warn!("content indexing for {}: {}", row.path(), e),
|
||||||
},
|
}
|
||||||
}
|
|
||||||
if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline {
|
|
||||||
cut = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()
|
if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline {
|
||||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
|
||||||
if cut {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||||
Ok(done)
|
Ok(done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,8 +235,7 @@ fn extract_scope_counts_only_files_an_extractor_claims() {
|
||||||
&conn_mutex,
|
&conn_mutex,
|
||||||
&records,
|
&records,
|
||||||
&stop,
|
&stop,
|
||||||
&config,
|
&mut crate::db::repo::DocEncoder::new().unwrap(),
|
||||||
&mut Bodies::new().unwrap(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub(crate) use batch::extract_scope_prepare;
|
||||||
pub(crate) use batch::max_text_file_size;
|
pub(crate) use batch::max_text_file_size;
|
||||||
pub use batch::{
|
pub use batch::{
|
||||||
cleanup_stale_index_entries, count_extract_scope, mark_oversize_pending_na,
|
cleanup_stale_index_entries, count_extract_scope, mark_oversize_pending_na,
|
||||||
process_batch_inserts, process_batch_updates, store_extracted, Bodies, ExtractCursor,
|
process_batch_inserts, process_batch_updates, store_extracted, ExtractCursor,
|
||||||
ExtractScope, RootRange, Stored,
|
ExtractScope, RootRange, Stored,
|
||||||
};
|
};
|
||||||
pub use counting::count_tree_entries_fast;
|
pub use counting::count_tree_entries_fast;
|
||||||
|
|
@ -29,7 +29,7 @@ pub(crate) use paths::{
|
||||||
};
|
};
|
||||||
pub(crate) use records::mtime_secs;
|
pub(crate) use records::mtime_secs;
|
||||||
pub use records::{
|
pub use records::{
|
||||||
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
|
classify_by_mtime, content_extractable, decide_content,
|
||||||
extract_and_store, fts_begin_bulk_write, fts_begin_tombstone_burst, fts_end_tombstone_burst,
|
extract_and_store, fts_begin_bulk_write, fts_begin_tombstone_burst, fts_end_tombstone_burst,
|
||||||
fts_finalize_after_text_indexing, get_file_hash, hash_failure_counts, outcome_body,
|
fts_finalize_after_text_indexing, get_file_hash, hash_failure_counts, outcome_body,
|
||||||
prepare_file_record, reset_run_warnings, ContentOutcome, DirRows, FileIndexAction,
|
prepare_file_record, reset_run_warnings, ContentOutcome, DirRows, FileIndexAction,
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,10 @@ pub enum FileIndexAction {
|
||||||
Insert,
|
Insert,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide what Phase 1 should do with a file. A file whose parent is not this
|
/// Decide what Phase 1 should do with a file from its stored mtime, `None`
|
||||||
/// directory (a resolved symlink target) must not be classified here: it
|
/// when it has no row. For a resolved symlink target that is the row under
|
||||||
/// would read as `Insert`, and `INSERT OR IGNORE` would silently not update
|
/// the target's own path: its link's directory rows would read it as
|
||||||
/// it. Use [`classify_by_mtime`] for those.
|
/// `Insert`, and `INSERT OR IGNORE` would silently not update it.
|
||||||
pub fn classify_for_indexing(name: &str, mtime: u64, rows: &DirRows) -> FileIndexAction {
|
|
||||||
classify_by_mtime(rows.get(name).copied(), mtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn classify_by_mtime(stored: Option<u64>, mtime: u64) -> FileIndexAction {
|
pub fn classify_by_mtime(stored: Option<u64>, mtime: u64) -> FileIndexAction {
|
||||||
match stored {
|
match stored {
|
||||||
Some(known) if known == mtime => FileIndexAction::Skip,
|
Some(known) if known == mtime => FileIndexAction::Skip,
|
||||||
|
|
|
||||||
|
|
@ -170,31 +170,8 @@ fn filtered_walk_hidden_root_still_walked() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_uses_mtime_against_the_existing_index() {
|
fn classify_uses_mtime_against_the_stored_row() {
|
||||||
let mut rows = DirRows::new();
|
// No row: new. Same mtime: nothing to do. Changed: re-read.
|
||||||
rows.insert("known.txt".to_string(), 100);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
classify_for_indexing("new.txt", 100, &rows),
|
|
||||||
FileIndexAction::Insert,
|
|
||||||
"a name absent from the directory's rows is new"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_for_indexing("known.txt", 100, &rows),
|
|
||||||
FileIndexAction::Skip,
|
|
||||||
"same mtime means nothing to do"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_for_indexing("known.txt", 101, &rows),
|
|
||||||
FileIndexAction::Update,
|
|
||||||
"a changed mtime means re-read"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn classify_by_mtime_matches_the_name_keyed_path() {
|
|
||||||
// Resolved symlink targets take this route: their row is found by
|
|
||||||
// exact path.
|
|
||||||
assert_eq!(classify_by_mtime(None, 100), FileIndexAction::Insert);
|
assert_eq!(classify_by_mtime(None, 100), FileIndexAction::Insert);
|
||||||
assert_eq!(classify_by_mtime(Some(100), 100), FileIndexAction::Skip);
|
assert_eq!(classify_by_mtime(Some(100), 100), FileIndexAction::Skip);
|
||||||
assert_eq!(classify_by_mtime(Some(99), 100), FileIndexAction::Update);
|
assert_eq!(classify_by_mtime(Some(99), 100), FileIndexAction::Update);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Transaction;
|
||||||
|
|
||||||
use crate::config::{Config, IgnoreSet};
|
use crate::config::{Config, IgnoreSet};
|
||||||
use crate::db::repo;
|
use crate::db::repo;
|
||||||
|
|
@ -40,19 +40,19 @@ impl Budget<'_> {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Applied {
|
pub enum Applied {
|
||||||
Done,
|
Done,
|
||||||
/// Budget spent. What was written is committed; the caller should re-queue
|
/// Budget spent. What was written stays written; the caller should
|
||||||
/// the same event with `resume_from` set to `done`.
|
/// re-queue the same event with `resume_from` set to `done`.
|
||||||
Unfinished {
|
Unfinished {
|
||||||
done: usize,
|
done: usize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply one filesystem event to the index. Missing files are treated as
|
/// Apply one filesystem event to the index, inside the caller's transaction,
|
||||||
/// no-ops (the pending Remove event resolves them); unchanged mtimes
|
/// which may hold many events. Missing files are treated as no-ops (the
|
||||||
/// short-circuit without touching the DB. See [`Budget`] for the one event
|
/// pending Remove event resolves them); unchanged mtimes short-circuit without
|
||||||
/// that is not small.
|
/// touching the DB. See [`Budget`] for the one event that is not small.
|
||||||
pub fn apply_fs_event(
|
pub fn apply_fs_event(
|
||||||
conn: &mut Connection,
|
tx: &Transaction<'_>,
|
||||||
event: &FsEvent,
|
event: &FsEvent,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
ignore: &IgnoreSet,
|
ignore: &IgnoreSet,
|
||||||
|
|
@ -61,18 +61,18 @@ pub fn apply_fs_event(
|
||||||
) -> Result<Applied, String> {
|
) -> Result<Applied, String> {
|
||||||
match event {
|
match event {
|
||||||
FsEvent::Create(p) | FsEvent::Modify(p) => {
|
FsEvent::Create(p) | FsEvent::Modify(p) => {
|
||||||
upsert_path(conn, p, config, ignore, registry, budget)
|
upsert_path(tx, p, config, ignore, registry, budget)
|
||||||
}
|
}
|
||||||
FsEvent::Remove(p) => remove_path(conn, p).map(|()| Applied::Done),
|
FsEvent::Remove(p) => remove_paths(tx, std::slice::from_ref(p)).map(|()| Applied::Done),
|
||||||
FsEvent::Rename { from, to } => {
|
FsEvent::Rename { from, to } => {
|
||||||
remove_path(conn, from)?;
|
remove_paths(tx, std::slice::from_ref(from))?;
|
||||||
upsert_path(conn, to, config, ignore, registry, budget)
|
upsert_path(tx, to, config, ignore, registry, budget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_path(
|
fn upsert_path(
|
||||||
conn: &mut Connection,
|
tx: &Transaction<'_>,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
ignore: &IgnoreSet,
|
ignore: &IgnoreSet,
|
||||||
|
|
@ -127,17 +127,37 @@ fn upsert_path(
|
||||||
if budget.spent() {
|
if budget.spent() {
|
||||||
return Ok(Applied::Unfinished { done });
|
return Ok(Applied::Unfinished { done });
|
||||||
}
|
}
|
||||||
upsert_file(conn, entry.path(), config, registry)?;
|
upsert_file(tx, entry.path(), config, registry)?;
|
||||||
done += 1;
|
done += 1;
|
||||||
}
|
}
|
||||||
Ok(Applied::Done)
|
Ok(Applied::Done)
|
||||||
} else {
|
} else {
|
||||||
upsert_file(conn, path, config, registry).map(|()| Applied::Done)
|
upsert_file(tx, path, config, registry).map(|()| Applied::Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `write` all or nothing inside the caller's transaction: a file whose
|
||||||
|
/// writes fail part-way must not keep a row that claims text its posting
|
||||||
|
/// never got.
|
||||||
|
fn atomically(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
write: impl FnOnce() -> Result<(), String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tx.execute_batch("SAVEPOINT one_path")
|
||||||
|
.map_err(|e| format!("begin incremental savepoint: {}", e))?;
|
||||||
|
match write() {
|
||||||
|
Ok(()) => tx
|
||||||
|
.execute_batch("RELEASE one_path")
|
||||||
|
.map_err(|e| format!("release incremental savepoint: {}", e)),
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.execute_batch("ROLLBACK TO one_path; RELEASE one_path");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_file(
|
fn upsert_file(
|
||||||
conn: &mut Connection,
|
tx: &Transaction<'_>,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
registry: &Registry,
|
registry: &Registry,
|
||||||
|
|
@ -153,16 +173,12 @@ fn upsert_file(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| format!("begin incremental tx: {}", e))?;
|
|
||||||
|
|
||||||
// Settled before the file is opened, as the full walk settles it: an
|
// Settled before the file is opened, as the full walk settles it: an
|
||||||
// unchanged file (a re-sent event, a `chmod`, the live view re-checking a
|
// unchanged file (a re-sent event, a `chmod`, the live view re-checking a
|
||||||
// row it shows) costs one lookup, never a read and a hash. Measured
|
// row it shows) costs one lookup, never a read and a hash. Measured
|
||||||
// (`benches/index.rs`, `unchanged_event`, 500 small text files): 3.3 ms
|
// (`benches/index.rs`, `directory_event::unchanged`, 500 small text
|
||||||
// hashing and sniffing each against 1.4 ms.
|
// files): 3.3 ms hashing and sniffing each against 1.4 ms.
|
||||||
let stored = repo::mtime_for_path(&tx, &db_path)?;
|
let stored = repo::mtime_for_path(tx, &db_path)?;
|
||||||
if stored.is_some() && stored == mtime_secs(&meta) {
|
if stored.is_some() && stored == mtime_secs(&meta) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -171,67 +187,54 @@ fn upsert_file(
|
||||||
let Some(rec) = prepare_file_record(&db_path, &meta, config, registry, &mut scratch) else {
|
let Some(rec) = prepare_file_record(&db_path, &meta, config, registry, &mut scratch) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let written = match stored {
|
atomically(tx, || {
|
||||||
Some(_) => repo::update_file_basic(&tx, &rec.as_new_file())?,
|
let written = match stored {
|
||||||
None => repo::insert_file(&tx, &rec.as_new_file())?,
|
Some(_) => repo::update_file_basic(tx, &rec.as_new_file())?,
|
||||||
};
|
None => repo::insert_file(tx, &rec.as_new_file())?,
|
||||||
// `None`: lost a race with another writer; the row that won is current
|
};
|
||||||
// enough.
|
// `None`: lost a race with another writer; the row that won is
|
||||||
let Some(file_id) = written else {
|
// current enough.
|
||||||
return Ok(());
|
let Some(file_id) = written else {
|
||||||
};
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
// A row that needs no content is already NA: both writers above set its
|
// A row that needs no content is already NA: both writers above set
|
||||||
// state from `rec`. That gate is also the only size gate on this path:
|
// its state from `rec`. That gate is also the only size gate on this
|
||||||
// `decide_content` has none, and falling through would read a
|
// path: `decide_content` has none, and falling through would read a
|
||||||
// multi-gigabyte `.txt` whole into memory.
|
// multi-gigabyte `.txt` whole into memory.
|
||||||
if rec.needs_content {
|
if rec.needs_content {
|
||||||
match rec.inline_text.as_deref() {
|
match rec.inline_text.as_deref() {
|
||||||
// `prepare_file_record` already read the whole file.
|
// `prepare_file_record` already read the whole file.
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
let zstd = repo::encode_one(text)?;
|
let zstd = repo::encode_one(text)?;
|
||||||
repo::store_pending_text(&tx, file_id, text, zstd.as_deref())?;
|
repo::store_pending_text(tx, file_id, text, zstd.as_deref())?;
|
||||||
|
}
|
||||||
|
None => extract_and_store(tx, file_id, rec.path(), rec.mime, registry, config)?,
|
||||||
}
|
}
|
||||||
None => extract_and_store(&tx, file_id, rec.path(), rec.mime, registry, config)?,
|
|
||||||
}
|
}
|
||||||
}
|
Ok(())
|
||||||
|
})
|
||||||
tx.commit()
|
|
||||||
.map_err(|e| format!("commit incremental tx: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_path(conn: &mut Connection, path: &Path) -> Result<(), String> {
|
/// Delete `paths` and everything indexed beneath them, inside the caller's
|
||||||
remove_paths(conn, std::slice::from_ref(&path.to_path_buf()), 1)
|
/// transaction. Pays off only when the caller has reduced the set to its
|
||||||
}
|
/// *roots*: then `rm -rf dir/` is a fixed handful of range-driven statements
|
||||||
|
/// ([`repo::delete_subtree`]) rather than five per file.
|
||||||
/// Delete `paths` and everything indexed beneath them, in transactions of at
|
pub fn remove_paths(tx: &Transaction<'_>, paths: &[std::path::PathBuf]) -> Result<(), String> {
|
||||||
/// most `chunk` paths. Pays off only when the caller has reduced the set to
|
for path in paths {
|
||||||
/// its *roots*: then `rm -rf dir/` is a fixed handful of range-driven
|
// A path the index cannot spell was never indexed, so there is
|
||||||
/// statements ([`repo::delete_subtree`]) rather than five per file.
|
// nothing to delete; its lossy spelling names another file (see
|
||||||
pub fn remove_paths(
|
// `path_to_db_string`), whose row and subtree this would delete.
|
||||||
conn: &mut Connection,
|
if path.to_str().is_none() {
|
||||||
paths: &[std::path::PathBuf],
|
continue;
|
||||||
chunk: usize,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
for batch in paths.chunks(chunk.max(1)) {
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| format!("begin incremental tx: {}", e))?;
|
|
||||||
for path in batch {
|
|
||||||
// A path the index cannot spell was never indexed, so there is
|
|
||||||
// nothing to delete; its lossy spelling names another file (see
|
|
||||||
// `path_to_db_string`), whose row and subtree this would delete.
|
|
||||||
if path.to_str().is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The stored key is canonicalized, but the file is already gone,
|
|
||||||
// so `canonicalize` cannot run; `db_key_for_missing_path` approximates it.
|
|
||||||
let path_str = db_key_for_missing_path(path);
|
|
||||||
repo::delete_file_by_path(&tx, &path_str)?;
|
|
||||||
repo::delete_subtree(&tx, &RootRange::for_root(&path_str))?;
|
|
||||||
}
|
}
|
||||||
tx.commit()
|
// The stored key is canonicalized, but the file is already gone, so
|
||||||
.map_err(|e| format!("commit incremental tx: {}", e))?;
|
// `canonicalize` cannot run; `db_key_for_missing_path` approximates it.
|
||||||
|
let path_str = db_key_for_missing_path(path);
|
||||||
|
atomically(tx, || {
|
||||||
|
repo::delete_file_by_path(tx, &path_str)?;
|
||||||
|
repo::delete_subtree(tx, &RootRange::for_root(&path_str)).map(|_| ())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +243,7 @@ pub fn remove_paths(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::open_or_recreate;
|
use crate::db::open_or_recreate;
|
||||||
use rusqlite::OptionalExtension;
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
|
|
@ -281,8 +284,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_resuming(&mut self, event: &FsEvent, budget: Duration, from: usize) -> Applied {
|
fn apply_resuming(&mut self, event: &FsEvent, budget: Duration, from: usize) -> Applied {
|
||||||
apply_fs_event(
|
let tx = self.conn.transaction().unwrap();
|
||||||
&mut self.conn,
|
let applied = apply_fs_event(
|
||||||
|
&tx,
|
||||||
event,
|
event,
|
||||||
&self.config,
|
&self.config,
|
||||||
&self.ignore,
|
&self.ignore,
|
||||||
|
|
@ -293,7 +297,9 @@ mod tests {
|
||||||
resume_from: from,
|
resume_from: from,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
applied
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&self, name: &str, content: &str) -> std::path::PathBuf {
|
fn write(&self, name: &str, content: &str) -> std::path::PathBuf {
|
||||||
|
|
@ -429,7 +435,9 @@ mod tests {
|
||||||
];
|
];
|
||||||
// Collapsed to its root, as the coordinator's `collapse_pending_removals` does.
|
// Collapsed to its root, as the coordinator's `collapse_pending_removals` does.
|
||||||
let roots = vec![reported[0].clone()];
|
let roots = vec![reported[0].clone()];
|
||||||
remove_paths(&mut f.conn, &roots, 200).unwrap();
|
let tx = f.conn.transaction().unwrap();
|
||||||
|
remove_paths(&tx, &roots).unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
assert_eq!(f.counts(), (1, 1, 1), "only tree2 survives");
|
assert_eq!(f.counts(), (1, 1, 1), "only tree2 survives");
|
||||||
let survivor = f.canonical(&f.dir.join("tree2").join("keep.txt"));
|
let survivor = f.canonical(&f.dir.join("tree2").join("keep.txt"));
|
||||||
assert!(f.row(&survivor).is_some());
|
assert!(f.row(&survivor).is_some());
|
||||||
|
|
@ -690,8 +698,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
std::fs::write(bad.join("inside.txt"), "body").unwrap();
|
std::fs::write(bad.join("inside.txt"), "body").unwrap();
|
||||||
|
|
||||||
|
let tx = f.conn.transaction().unwrap();
|
||||||
let outcome = apply_fs_event(
|
let outcome = apply_fs_event(
|
||||||
&mut f.conn,
|
&tx,
|
||||||
&FsEvent::Create(bad),
|
&FsEvent::Create(bad),
|
||||||
&f.config,
|
&f.config,
|
||||||
&f.ignore,
|
&f.ignore,
|
||||||
|
|
@ -707,6 +716,7 @@ mod tests {
|
||||||
Ok(Applied::Done),
|
Ok(Applied::Done),
|
||||||
"an unindexable subtree is not a failure"
|
"an unindexable subtree is not a failure"
|
||||||
);
|
);
|
||||||
|
tx.commit().unwrap();
|
||||||
assert_eq!(f.counts().0, 0, "and nothing under it is indexed");
|
assert_eq!(f.counts().0, 0, "and nothing under it is indexed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ use crate::extract::Registry;
|
||||||
use crate::file_handling::{
|
use crate::file_handling::{
|
||||||
cleanup_stale_index_entries, count_tree_entries_fast, fts_begin_bulk_write,
|
cleanup_stale_index_entries, count_tree_entries_fast, fts_begin_bulk_write,
|
||||||
fts_finalize_after_text_indexing, mark_oversize_pending_na, normalize_root_string,
|
fts_finalize_after_text_indexing, mark_oversize_pending_na, normalize_root_string,
|
||||||
process_batch_inserts, process_batch_updates, store_extracted, Bodies, ExtractCursor,
|
process_batch_inserts, process_batch_updates, store_extracted, ExtractCursor, ExtractScope,
|
||||||
ExtractScope, FileIndexAction, OwnedNewFile, RootRange,
|
FileIndexAction, OwnedNewFile, RootRange,
|
||||||
};
|
};
|
||||||
use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent};
|
use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent};
|
||||||
|
|
||||||
|
|
@ -153,8 +153,7 @@ type WriteBatch = fn(
|
||||||
&Arc<Mutex<Connection>>,
|
&Arc<Mutex<Connection>>,
|
||||||
&[OwnedNewFile],
|
&[OwnedNewFile],
|
||||||
&Arc<AtomicBool>,
|
&Arc<AtomicBool>,
|
||||||
&Config,
|
&mut crate::db::repo::DocEncoder,
|
||||||
&mut Bodies,
|
|
||||||
) -> Result<(), String>;
|
) -> Result<(), String>;
|
||||||
|
|
||||||
/// Write `rows` through `write`, then clear them; on an error they stay.
|
/// Write `rows` through `write`, then clear them; on an error they stay.
|
||||||
|
|
@ -163,13 +162,7 @@ fn flush(
|
||||||
rows: &mut Vec<OwnedNewFile>,
|
rows: &mut Vec<OwnedNewFile>,
|
||||||
write: WriteBatch,
|
write: WriteBatch,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
write(
|
write(&cx.conn_mutex, rows, cx.stop_flag, &mut cx.encoder)?;
|
||||||
&cx.conn_mutex,
|
|
||||||
rows,
|
|
||||||
cx.stop_flag,
|
|
||||||
cx.config,
|
|
||||||
&mut cx.bodies,
|
|
||||||
)?;
|
|
||||||
rows.clear();
|
rows.clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +518,7 @@ impl RootPipeline {
|
||||||
ready,
|
ready,
|
||||||
cx.stop_flag,
|
cx.stop_flag,
|
||||||
deadline,
|
deadline,
|
||||||
&mut cx.bodies,
|
&mut cx.encoder,
|
||||||
)?;
|
)?;
|
||||||
if stored.consumed > 0 {
|
if stored.consumed > 0 {
|
||||||
// The last row *written*, not the last fetched. The whole path,
|
// The last row *written*, not the last fetched. The whole path,
|
||||||
|
|
@ -574,8 +567,8 @@ pub(super) struct RunCx<'a> {
|
||||||
/// Writer time one root's turn may take before the round moves on
|
/// Writer time one root's turn may take before the round moves on
|
||||||
/// ([`crate::config::ProcessingConfig::writer_turn_slice_ms`]).
|
/// ([`crate::config::ProcessingConfig::writer_turn_slice_ms`]).
|
||||||
pub(super) slice: Duration,
|
pub(super) slice: Duration,
|
||||||
/// The run's one set of compression buffers, shared by every write.
|
/// The run's one compression context, shared by every write.
|
||||||
pub(super) bodies: Bodies,
|
pub(super) encoder: crate::db::repo::DocEncoder,
|
||||||
/// Rows with no file behind them, per-directory plus the vanished sweep.
|
/// Rows with no file behind them, per-directory plus the vanished sweep.
|
||||||
pub(super) stale_candidates: Vec<String>,
|
pub(super) stale_candidates: Vec<String>,
|
||||||
/// Paths reached via symlink; their rows may live outside every root.
|
/// Paths reached via symlink; their rows may live outside every root.
|
||||||
|
|
@ -600,7 +593,7 @@ impl<'a> RunCx<'a> {
|
||||||
registry: Arc::new(Registry::default_set()),
|
registry: Arc::new(Registry::default_set()),
|
||||||
batch: config.processing.batch_size.max(1),
|
batch: config.processing.batch_size.max(1),
|
||||||
slice: Duration::from_millis(config.processing.writer_turn_slice_ms),
|
slice: Duration::from_millis(config.processing.writer_turn_slice_ms),
|
||||||
bodies: Bodies::new()?,
|
encoder: crate::db::repo::DocEncoder::new()?,
|
||||||
stale_candidates: Vec::new(),
|
stale_candidates: Vec::new(),
|
||||||
aliased_paths: HashSet::new(),
|
aliased_paths: HashSet::new(),
|
||||||
stale_cleanup_ok: true,
|
stale_cleanup_ok: true,
|
||||||
|
|
|
||||||
|
|
@ -474,12 +474,10 @@ impl Loop {
|
||||||
return WindowUpdate::Unchanged;
|
return WindowUpdate::Unchanged;
|
||||||
};
|
};
|
||||||
let cut = match tier {
|
let cut = match tier {
|
||||||
|
// Unlike in the passes, the body may have stopped matching here.
|
||||||
ContentTier::Exact => {
|
ContentTier::Exact => {
|
||||||
// Unlike in the passes, the body may have stopped matching
|
crate::search::cascade::body_match(&query.pattern, text, &mut String::new())
|
||||||
// here; an unmarked window says so.
|
.map(|m| m.snippet)
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
crate::search::cascade::text_snippet(&query.pattern, text, &folded)
|
|
||||||
.filter(|snip| !snip.ranges.is_empty())
|
|
||||||
}
|
}
|
||||||
ContentTier::Fuzzy => match &self.fuzzy {
|
ContentTier::Fuzzy => match &self.fuzzy {
|
||||||
Some(bitap) => crate::search::cascade::fuzzy_snippet(bitap, text).map(|(_, s)| s),
|
Some(bitap) => crate::search::cascade::fuzzy_snippet(bitap, text).map(|(_, s)| s),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ use regex::{Regex, RegexBuilder};
|
||||||
|
|
||||||
use super::translator::TranslateError;
|
use super::translator::TranslateError;
|
||||||
use crate::search::prefilter::Required;
|
use crate::search::prefilter::Required;
|
||||||
use crate::snippet;
|
|
||||||
|
|
||||||
/// Keeps a hostile pattern (`a{1000000}{1000}`) from ballooning the program.
|
/// Keeps a hostile pattern (`a{1000000}{1000}`) from ballooning the program.
|
||||||
const REGEX_SIZE_LIMIT: usize = 4 << 20;
|
const REGEX_SIZE_LIMIT: usize = 4 << 20;
|
||||||
|
|
@ -79,6 +78,21 @@ impl WildcardPattern {
|
||||||
&self.anchored_cs
|
&self.anchored_cs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Non-overlapping occurrences in `text`, stopping at 1000 where the
|
||||||
|
/// cascade's `count_frac` saturates, and the first of them, in one sweep.
|
||||||
|
pub fn count_and_first(
|
||||||
|
&self,
|
||||||
|
text: &str,
|
||||||
|
case_insensitive: bool,
|
||||||
|
) -> (usize, Option<Range<usize>>) {
|
||||||
|
let mut found = self
|
||||||
|
.search(case_insensitive)
|
||||||
|
.find_iter(text)
|
||||||
|
.take(COUNT_CAP);
|
||||||
|
let first = found.next().map(|m| m.range());
|
||||||
|
(first.as_ref().map_or(0, |_| 1 + found.count()), first)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TermPattern {
|
impl TermPattern {
|
||||||
|
|
@ -246,38 +260,6 @@ impl TermPattern {
|
||||||
TermPattern::Wildcard(w) => w.search(case_insensitive).find(text).map(|m| m.range()),
|
TermPattern::Wildcard(w) => w.search(case_insensitive).find(text).map(|m| m.range()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`TermPattern::find_first`], case-insensitive, on a pre-folded haystack.
|
|
||||||
pub fn find_first_folded(&self, folded: &str) -> Option<Range<usize>> {
|
|
||||||
match self {
|
|
||||||
TermPattern::Empty => None,
|
|
||||||
TermPattern::Literal(l) => {
|
|
||||||
let pos = folded.find(&l.folded)?;
|
|
||||||
Some(pos..pos + l.text.len())
|
|
||||||
}
|
|
||||||
TermPattern::Wildcard(w) => w.search_ci.find(folded).map(|m| m.range()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case-insensitive [`TermPattern::count`] against an already-folded haystack.
|
|
||||||
pub fn count_folded(&self, folded: &str) -> usize {
|
|
||||||
match self {
|
|
||||||
TermPattern::Empty => 0,
|
|
||||||
TermPattern::Literal(l) => snippet::count_occurrences(folded, &l.folded),
|
|
||||||
TermPattern::Wildcard(w) => w.search_ci.find_iter(folded).take(COUNT_CAP).count(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case-sensitive, non-overlapping occurrence count; the case-insensitive
|
|
||||||
/// one is [`TermPattern::count_folded`]. Wildcards stop at 1000, where the
|
|
||||||
/// cascade's `count_frac` saturates anyway.
|
|
||||||
pub fn count(&self, text: &str) -> usize {
|
|
||||||
match self {
|
|
||||||
TermPattern::Empty => 0,
|
|
||||||
TermPattern::Literal(l) => snippet::count_occurrences(text, &l.text),
|
|
||||||
TermPattern::Wildcard(w) => w.search_cs.find_iter(text).take(COUNT_CAP).count(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A compiled `regex:` query. Case-insensitive by default (override with
|
/// A compiled `regex:` query. Case-insensitive by default (override with
|
||||||
|
|
@ -369,9 +351,13 @@ impl RegexQuery {
|
||||||
self.re.find(text).map(|m| m.range())
|
self.re.find(text).map(|m| m.range())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-overlapping occurrence count, capped at 1000.
|
/// Non-overlapping occurrences, capped at 1000, and the first of them,
|
||||||
pub fn count(&self, text: &str) -> usize {
|
/// in one sweep. Against a count and then a second search for the first
|
||||||
self.re.find_iter(text).take(COUNT_CAP).count()
|
/// (`searchtime`, cargo registry): within noise to 3% faster.
|
||||||
|
pub fn count_and_first(&self, text: &str) -> (usize, Option<Range<usize>>) {
|
||||||
|
let mut found = self.re.find_iter(text).take(COUNT_CAP);
|
||||||
|
let first = found.next().map(|m| m.range());
|
||||||
|
(first.as_ref().map_or(0, |_| 1 + found.count()), first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,7 +411,6 @@ mod tests {
|
||||||
assert!(p.is_empty(), "{:?}", parts);
|
assert!(p.is_empty(), "{:?}", parts);
|
||||||
assert!(!p.whole_match("anything", true));
|
assert!(!p.whole_match("anything", true));
|
||||||
assert!(p.find_first("anything", true).is_none());
|
assert!(p.find_first("anything", true).is_none());
|
||||||
assert_eq!(p.count("anything"), 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,10 +497,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn count_is_nonoverlapping_and_capped() {
|
fn count_is_nonoverlapping_and_capped() {
|
||||||
let p = TermPattern::build(&[part("a*b", true)]).unwrap();
|
let TermPattern::Wildcard(w) = TermPattern::build(&[part("a*b", true)]).unwrap() else {
|
||||||
assert_eq!(p.count("ab ab ab"), 3);
|
panic!("a star makes a wildcard");
|
||||||
|
};
|
||||||
|
assert_eq!(w.count_and_first("xx ab ab ab", false), (3, Some(3..5)));
|
||||||
let many = "ab ".repeat(2000);
|
let many = "ab ".repeat(2000);
|
||||||
assert_eq!(p.count(&many), 1000);
|
assert_eq!(w.count_and_first(&many, false).0, 1000);
|
||||||
|
assert_eq!(w.count_and_first("AB aB", false), (0, None), "exact case");
|
||||||
|
assert_eq!(w.count_and_first("AB aB", true), (2, Some(0..2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -527,8 +516,6 @@ mod tests {
|
||||||
assert_eq!(p.find_first("my Report.pdf", false), Some(3..9));
|
assert_eq!(p.find_first("my Report.pdf", false), Some(3..9));
|
||||||
assert_eq!(p.find_first("my report.pdf", true), Some(3..9));
|
assert_eq!(p.find_first("my report.pdf", true), Some(3..9));
|
||||||
assert_eq!(p.find_first("my report.pdf", false), None);
|
assert_eq!(p.find_first("my report.pdf", false), None);
|
||||||
assert_eq!(p.count("report Report"), 1);
|
|
||||||
assert_eq!(p.count_folded("report report"), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -585,7 +572,8 @@ mod tests {
|
||||||
let r = RegexQuery::new("b[aeiou]d").unwrap();
|
let r = RegexQuery::new("b[aeiou]d").unwrap();
|
||||||
let hay = "bad bed bodkin";
|
let hay = "bad bed bodkin";
|
||||||
assert_eq!(r.find_first(hay), Some(0..3));
|
assert_eq!(r.find_first(hay), Some(0..3));
|
||||||
assert_eq!(r.count(hay), 3);
|
assert_eq!(r.count_and_first(hay), (3, Some(0..3)));
|
||||||
|
assert_eq!(r.count_and_first("nothing here"), (0, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -48,39 +48,72 @@ const CANCEL_CHECK_ROWS: usize = 256;
|
||||||
/// Snippet window budget: the GUI trims to column width; mouseover shows the rest.
|
/// Snippet window budget: the GUI trims to column width; mouseover shows the rest.
|
||||||
const SNIPPET_WINDOW_BYTES: usize = 600;
|
const SNIPPET_WINDOW_BYTES: usize = 600;
|
||||||
|
|
||||||
const SNIPPET_OPTS: snippet::Options = snippet::Options {
|
/// How one document body matched the term: stage 5 when it occurs exactly
|
||||||
approx_bytes: SNIPPET_WINDOW_BYTES,
|
/// as typed, 6 when only in another case; `count` is what the stage ranks
|
||||||
};
|
/// on, `snippet` the window around the first occurrence.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
/// The Content Match snippet for one document body, cut exactly as the
|
pub struct BodyMatch {
|
||||||
/// full-text passes cut it; shared with [`crate::live`] so a re-cut matches.
|
pub stage: u8,
|
||||||
/// `folded` must be `text` ASCII-lowercased; the fold preserves byte length,
|
pub count: usize,
|
||||||
/// so offsets found in it slice `text`.
|
pub snippet: snippet::Snippet,
|
||||||
pub fn text_snippet(
|
|
||||||
pattern: &crate::query::pattern::TermPattern,
|
|
||||||
text: &str,
|
|
||||||
folded: &str,
|
|
||||||
) -> Option<snippet::Snippet> {
|
|
||||||
text_snippet_counted(pattern, text, folded)
|
|
||||||
.map(|(snip, _)| snip)
|
|
||||||
.or_else(|| {
|
|
||||||
pattern.find_first_folded(folded).map(|r| {
|
|
||||||
// A greedy pattern can match megabytes; clamp before the window.
|
|
||||||
let r = clamp_match_range(text, r, SNIPPET_WINDOW_BYTES);
|
|
||||||
snippet::window_around(text, (r.start, r.end), &SNIPPET_OPTS)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`text_snippet`] for a literal pattern, plus the case-insensitive
|
/// The term's match in one document body, verified and cut exactly as the
|
||||||
/// occurrence count found on the way. `None` when the pattern is not literal.
|
/// full-text pass does; `None` when it does not occur. Shared with
|
||||||
pub fn text_snippet_counted(
|
/// [`crate::live`], so a re-cut matches. `lower` is reused scratch for the
|
||||||
|
/// folded body a literal is searched in.
|
||||||
|
///
|
||||||
|
/// One sweep per body where there were three: a literal counts both cases in
|
||||||
|
/// its one folded scan, and a wildcard is never folded, its exact-case regex
|
||||||
|
/// giving the count and the first match together. Measured against folding
|
||||||
|
/// every body, then a folded scan, a case-sensitive recount and, for a
|
||||||
|
/// wildcard, a third scan for the window (`searchtime`, cargo registry, 41k
|
||||||
|
/// files): `Hash*Map` 103.5–104.2 → 97.4–98.0 ms, `Serial*ize` 56–59 → 53.5;
|
||||||
|
/// literals within ±2% (`serialize` 38.6–39.2 → 38.7–39.2).
|
||||||
|
pub fn body_match(
|
||||||
pattern: &crate::query::pattern::TermPattern,
|
pattern: &crate::query::pattern::TermPattern,
|
||||||
text: &str,
|
text: &str,
|
||||||
folded: &str,
|
lower: &mut String,
|
||||||
) -> Option<(snippet::Snippet, usize)> {
|
) -> Option<BodyMatch> {
|
||||||
let term = pattern.literal_folded()?;
|
use crate::query::pattern::TermPattern;
|
||||||
Some(snippet::extract_folded(text, folded, term, &SNIPPET_OPTS))
|
match pattern {
|
||||||
|
TermPattern::Empty => None,
|
||||||
|
TermPattern::Literal(_) => {
|
||||||
|
let (typed, term) = (pattern.literal()?, pattern.literal_folded()?);
|
||||||
|
fold_into(lower, text);
|
||||||
|
let found = snippet::extract_folded(text, lower, term, typed, SNIPPET_WINDOW_BYTES)?;
|
||||||
|
let (stage, count) = match found.exact {
|
||||||
|
0 => (6, found.count),
|
||||||
|
exact => (5, exact),
|
||||||
|
};
|
||||||
|
Some(BodyMatch {
|
||||||
|
stage,
|
||||||
|
count,
|
||||||
|
snippet: found.snippet,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
TermPattern::Wildcard(w) => {
|
||||||
|
let (stage, (count, first)) = match w.count_and_first(text, false) {
|
||||||
|
(0, _) => (6, w.count_and_first(text, true)),
|
||||||
|
exact => (5, exact),
|
||||||
|
};
|
||||||
|
// A greedy pattern can match megabytes; clamp before the window.
|
||||||
|
let r = clamp_match_range(text, first?, SNIPPET_WINDOW_BYTES);
|
||||||
|
Some(BodyMatch {
|
||||||
|
stage,
|
||||||
|
count,
|
||||||
|
snippet: snippet::window_around(text, (r.start, r.end), SNIPPET_WINDOW_BYTES),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold `text` into `dst`, reusing its allocation. The ASCII fold is
|
||||||
|
/// byte-length preserving, so folded offsets are valid in the original.
|
||||||
|
fn fold_into(dst: &mut String, text: &str) {
|
||||||
|
dst.clear();
|
||||||
|
dst.push_str(text);
|
||||||
|
dst.make_ascii_lowercase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fuzzy full-text match in one document body: occurrence count within
|
/// The fuzzy full-text match in one document body: occurrence count within
|
||||||
|
|
@ -92,7 +125,7 @@ pub fn fuzzy_snippet(
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Option<(usize, snippet::Snippet)> {
|
) -> Option<(usize, snippet::Snippet)> {
|
||||||
let (count, first) = bitap.count_and_first(text.as_bytes());
|
let (count, first) = bitap.count_and_first(text.as_bytes());
|
||||||
first.map(|range| (count, snippet::window_around(text, range, &SNIPPET_OPTS)))
|
first.map(|range| (count, snippet::window_around(text, range, SNIPPET_WINDOW_BYTES)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
|
@ -465,13 +498,20 @@ impl<'a> Cx<'a> {
|
||||||
self.flush_pass(deferred.hits, deferred.overflowed);
|
self.flush_pass(deferred.hits, deferred.overflowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort + cut back to display-limit room once past the cap; true if anything dropped.
|
/// Cut a deferred buffer back to display-limit room once past the cap;
|
||||||
|
/// true if anything dropped. Partitioned, not sorted: [`Cx::flush_pass`]
|
||||||
|
/// sorts what is left. Measured against a full sort (`searchtime`, cargo
|
||||||
|
/// registry, terms every path holds): `1949cf` 25–30 → 21–22 ms, `src`
|
||||||
|
/// 40–42 → 37–39, `crates` 89–99 → 86–87.
|
||||||
fn enforce_cap(&self, buf: &mut Vec<SearchHit>) -> bool {
|
fn enforce_cap(&self, buf: &mut Vec<SearchHit>) -> bool {
|
||||||
if buf.len() <= self.buffer_cap() {
|
if buf.len() <= self.buffer_cap() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf.sort_by(rank_order);
|
let keep = self.remaining();
|
||||||
buf.truncate(self.remaining());
|
if keep > 0 {
|
||||||
|
buf.select_nth_unstable_by(keep - 1, rank_order);
|
||||||
|
}
|
||||||
|
buf.truncate(keep);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,6 @@ fn fts_match_sql(filter_sql: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold `text` into `dst`, reusing its allocation. The ASCII fold is
|
|
||||||
/// byte-length preserving, so folded offsets are valid in the original.
|
|
||||||
fn fold_into(dst: &mut String, text: &str) {
|
|
||||||
dst.clear();
|
|
||||||
dst.push_str(text);
|
|
||||||
dst.make_ascii_lowercase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The segment pass A's path-tier prefilter runs on: the longest one
|
/// The segment pass A's path-tier prefilter runs on: the longest one
|
||||||
/// containing no path separator, or `None` when every segment has one. Any
|
/// containing no path separator, or `None` when every segment has one. Any
|
||||||
/// single segment is a superset test, and a separator-free one cannot span
|
/// single segment is a superset test, and a separator-free one cannot span
|
||||||
|
|
@ -157,7 +149,6 @@ impl<'a> Cx<'a> {
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut buf: Vec<SearchHit> = Vec::new();
|
let mut buf: Vec<SearchHit> = Vec::new();
|
||||||
let mut overflowed = false;
|
|
||||||
let mut deferred = Deferred::default();
|
let mut deferred = Deferred::default();
|
||||||
let mut scanned = 0usize;
|
let mut scanned = 0usize;
|
||||||
let mut clock = FlushClock::new();
|
let mut clock = FlushClock::new();
|
||||||
|
|
@ -189,8 +180,9 @@ impl<'a> Cx<'a> {
|
||||||
match classify(self, row, file_id, &path, &path[name_at..])? {
|
match classify(self, row, file_id, &path, &path[name_at..])? {
|
||||||
RowHit::Skip => {}
|
RowHit::Skip => {}
|
||||||
RowHit::Emit(hit) => {
|
RowHit::Emit(hit) => {
|
||||||
|
// No cap here: `flush_if_due` hands the buffer over at
|
||||||
|
// every `batch` rows, so only deferred hits accumulate.
|
||||||
buf.push(hit);
|
buf.push(hit);
|
||||||
overflowed |= self.enforce_cap(&mut buf);
|
|
||||||
self.flush_if_due(&mut buf, &mut clock);
|
self.flush_if_due(&mut buf, &mut clock);
|
||||||
// Stop once the display limit is full. Recorded, not
|
// Stop once the display limit is full. Recorded, not
|
||||||
// turned into `limited` here: `flush_pass` owns that call.
|
// turned into `limited` here: `flush_pass` owns that call.
|
||||||
|
|
@ -213,7 +205,7 @@ impl<'a> Cx<'a> {
|
||||||
Some(slot) => *self.deferred_mut(slot) = deferred,
|
Some(slot) => *self.deferred_mut(slot) = deferred,
|
||||||
None => debug_assert!(deferred.hits.is_empty(), "deferred hits with no slot"),
|
None => debug_assert!(deferred.hits.is_empty(), "deferred hits with no slot"),
|
||||||
}
|
}
|
||||||
self.flush_pass(buf, overflowed);
|
self.flush_pass(buf, false);
|
||||||
// After the flush, so it cannot be undone by one that truncated nothing;
|
// After the flush, so it cannot be undone by one that truncated nothing;
|
||||||
// without this a cut set can report itself complete.
|
// without this a cut set can report itself complete.
|
||||||
self.limited |= cut_short;
|
self.limited |= cut_short;
|
||||||
|
|
@ -329,41 +321,24 @@ impl<'a> Cx<'a> {
|
||||||
let Some(text) = stored_text(row, &mut doc)? else {
|
let Some(text) = stored_text(row, &mut doc)? else {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
};
|
};
|
||||||
// Fold once: count, first match and snippet all need it, even on
|
let Some(m) = super::body_match(pattern, text, &mut lower) else {
|
||||||
// rows about to be dropped.
|
return Ok(RowHit::Skip);
|
||||||
fold_into(&mut lower, text);
|
|
||||||
let (count, stage, snip) = match super::text_snippet_counted(pattern, text, &lower) {
|
|
||||||
Some((snip, count_ci)) => {
|
|
||||||
if count_ci == 0 {
|
|
||||||
return Ok(RowHit::Skip);
|
|
||||||
}
|
|
||||||
let count_cs = pattern.count(text);
|
|
||||||
if count_cs > 0 {
|
|
||||||
(count_cs, 5, Some(snip))
|
|
||||||
} else {
|
|
||||||
(count_ci, 6, Some(snip))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let count_cs = pattern.count(text);
|
|
||||||
let (count, stage) = if count_cs > 0 {
|
|
||||||
(count_cs, 5)
|
|
||||||
} else {
|
|
||||||
let count_ci = pattern.count_folded(&lower);
|
|
||||||
if count_ci == 0 {
|
|
||||||
return Ok(RowHit::Skip);
|
|
||||||
}
|
|
||||||
(count_ci, 6)
|
|
||||||
};
|
|
||||||
(count, stage, super::text_snippet(pattern, text, &lower))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let rank = stage as f64 + count_frac(count);
|
let rank = m.stage as f64 + count_frac(m.count);
|
||||||
if !cx.regex_accepts(file_id, path, Some(text))? {
|
if !cx.regex_accepts(file_id, path, Some(text))? {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
}
|
}
|
||||||
|
|
||||||
row_hit(row, file_id, path, name, rank, stage, snip, false)
|
row_hit(
|
||||||
|
row,
|
||||||
|
file_id,
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
rank,
|
||||||
|
m.stage,
|
||||||
|
Some(m.snippet),
|
||||||
|
false,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -529,15 +504,12 @@ impl<'a> Cx<'a> {
|
||||||
let Some(text) = stored_text(row, &mut doc)? else {
|
let Some(text) = stored_text(row, &mut doc)? else {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
};
|
};
|
||||||
let count = re.count(text);
|
let (count, Some(first)) = re.count_and_first(text) else {
|
||||||
if count == 0 {
|
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
}
|
};
|
||||||
// A greedy user regex can match megabytes; clamp before the window.
|
// A greedy user regex can match megabytes; clamp before the window.
|
||||||
let snip = re.find_first(text).map(|r| {
|
let r = clamp_match_range(text, first, SNIPPET_WINDOW_BYTES);
|
||||||
let r = clamp_match_range(text, r, SNIPPET_WINDOW_BYTES);
|
let snip = snippet::window_around(text, (r.start, r.end), SNIPPET_WINDOW_BYTES);
|
||||||
snippet::window_around(text, (r.start, r.end), &SNIPPET_OPTS)
|
|
||||||
});
|
|
||||||
row_hit(
|
row_hit(
|
||||||
row,
|
row,
|
||||||
file_id,
|
file_id,
|
||||||
|
|
@ -545,7 +517,7 @@ impl<'a> Cx<'a> {
|
||||||
name,
|
name,
|
||||||
6.0 + count_frac(count),
|
6.0 + count_frac(count),
|
||||||
6,
|
6,
|
||||||
snip,
|
Some(snip),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
//! database in RAM (the gap widens on cold or encrypted storage). An innocent
|
//! database in RAM (the gap widens on cold or encrypted storage). An innocent
|
||||||
//! edit can lose this, so a test reads the query plan back and fails on
|
//! edit can lose this, so a test reads the query plan back and fails on
|
||||||
//! anything but a covering scan.
|
//! anything but a covering scan.
|
||||||
|
//!
|
||||||
|
//! The index exists only once a run has finished (see
|
||||||
|
//! [`crate::db::repo::build_hash_index`]); before that the same query sorts
|
||||||
|
//! the table, slower but with the same answer.
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::BinaryHeap;
|
use std::collections::BinaryHeap;
|
||||||
|
|
@ -239,6 +243,8 @@ mod tests {
|
||||||
.expect("unique path");
|
.expect("unique path");
|
||||||
}
|
}
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
|
// As a finished run's maintenance leaves it.
|
||||||
|
crate::db::repo::build_hash_index(&conn).unwrap();
|
||||||
(scratch, p)
|
(scratch, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,6 +288,28 @@ mod tests {
|
||||||
assert_eq!(groups[1].members[0].1, "a1.txt", "members path-ordered");
|
assert_eq!(groups[1].members[0].1, "a1.txt", "members path-ordered");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fresh index has no hash index until its first run's maintenance
|
||||||
|
/// builds one; the listing must not depend on it, only get faster.
|
||||||
|
#[test]
|
||||||
|
fn groups_are_the_same_before_the_hash_index_is_built() {
|
||||||
|
let (_dir, p) = seed_db();
|
||||||
|
let indexed = find_duplicate_groups(&p, 10).unwrap();
|
||||||
|
let conn = db::open_existing(&p, true).unwrap();
|
||||||
|
conn.execute_batch("DROP INDEX idx_files_hash;").unwrap();
|
||||||
|
assert_eq!(find_duplicate_groups(&p, 10).unwrap(), indexed);
|
||||||
|
|
||||||
|
crate::db::repo::maintain(&conn, &p).unwrap();
|
||||||
|
let rebuilt: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'idx_files_hash'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rebuilt, 1, "maintenance builds the missing index");
|
||||||
|
assert_eq!(find_duplicate_groups(&p, 10).unwrap(), indexed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn limit_keeps_the_largest_groups() {
|
fn limit_keeps_the_largest_groups() {
|
||||||
let (_dir, p) = seed_db();
|
let (_dir, p) = seed_db();
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,18 @@
|
||||||
//! byte are set), so neither side is folded. Measured: folding the haystack
|
//! byte are set), so neither side is folded. Measured: folding the haystack
|
||||||
//! cost a copy per scanned field and per stored document.
|
//! cost a copy per scanned field and per stored document.
|
||||||
|
|
||||||
|
use std::ops::ControlFlow;
|
||||||
|
|
||||||
use crate::query::split::CascadeQuery;
|
use crate::query::split::CascadeQuery;
|
||||||
|
|
||||||
/// Registers: one per error count `0..=k`. [`edit_budget`]'s ladder against a
|
/// Registers: one per error count `0..=k`. [`edit_budget`]'s ladder against a
|
||||||
/// 64-byte pattern caps `k` at 21; [`Bitap::new`] rejects more.
|
/// 64-byte pattern caps `k` at 21; [`Bitap::new`] rejects more.
|
||||||
const MAX_REGISTERS: usize = 22;
|
const MAX_REGISTERS: usize = 22;
|
||||||
|
|
||||||
|
/// [`Bitap::step`]'s register count, taken from `k` at run time rather than
|
||||||
|
/// fixed by [`Bitap::each_match`]'s dispatch.
|
||||||
|
const RUNTIME_K: usize = usize::MAX;
|
||||||
|
|
||||||
pub struct Bitap {
|
pub struct Bitap {
|
||||||
/// `masks[c]` has bit `i` set iff `pattern[i] == c` ignoring ASCII case.
|
/// `masks[c]` has bit `i` set iff `pattern[i] == c` ignoring ASCII case.
|
||||||
masks: [u64; 256],
|
masks: [u64; 256],
|
||||||
|
|
@ -19,6 +25,8 @@ pub struct Bitap {
|
||||||
rev_masks: [u64; 256],
|
rev_masks: [u64; 256],
|
||||||
len: usize,
|
len: usize,
|
||||||
k: usize,
|
k: usize,
|
||||||
|
/// The bit that says the whole pattern has matched: `1 << (len - 1)`.
|
||||||
|
done: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bitap {
|
impl Bitap {
|
||||||
|
|
@ -44,6 +52,7 @@ impl Bitap {
|
||||||
rev_masks,
|
rev_masks,
|
||||||
len: pattern.len(),
|
len: pattern.len(),
|
||||||
k,
|
k,
|
||||||
|
done: 1 << (pattern.len() - 1),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,31 +65,75 @@ impl Bitap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance all registers by one haystack byte; returns the smallest error
|
/// Advance all registers by one haystack byte; `true` when the full
|
||||||
/// count d at which the full pattern just matched. `masks` selects the
|
/// pattern just matched within `k` errors. `masks` selects the scan
|
||||||
/// scan direction: [`Bitap::masks`] forwards, [`Bitap::rev_masks`] back.
|
/// direction: [`Bitap::masks`] forwards, [`Bitap::rev_masks`] back.
|
||||||
#[inline]
|
///
|
||||||
fn step(&self, masks: &[u64; 256], r: &mut [u64], byte: u8) -> Option<usize> {
|
/// Registers only grow with the error count (`r[d] ⊇ r[d-1]`, from
|
||||||
|
/// `reset` on by induction), so `r[k]` alone answers "matched", and
|
||||||
|
/// [`Bitap::errors`] finds how well only when it did. Measured against
|
||||||
|
/// testing every register every byte (`searchtime`, cargo registry, 41k
|
||||||
|
/// files): `~recieve` 361 → 310 ms, `~serialze` 453 → 397, `~HashMpa`
|
||||||
|
/// 569 → 517.
|
||||||
|
#[inline(always)]
|
||||||
|
fn step<const K: usize>(
|
||||||
|
&self,
|
||||||
|
masks: &[u64; 256],
|
||||||
|
r: &mut [u64; MAX_REGISTERS],
|
||||||
|
byte: u8,
|
||||||
|
) -> bool {
|
||||||
|
let k = if K == RUNTIME_K { self.k } else { K };
|
||||||
let mask = masks[byte as usize];
|
let mask = masks[byte as usize];
|
||||||
let done = 1u64 << (self.len - 1);
|
|
||||||
let mut hit = None;
|
|
||||||
let mut prev_old = r[0]; // R_old[d-1] for the d-th iteration
|
let mut prev_old = r[0]; // R_old[d-1] for the d-th iteration
|
||||||
r[0] = ((r[0] << 1) | 1) & mask;
|
r[0] = ((r[0] << 1) | 1) & mask;
|
||||||
if r[0] & done != 0 {
|
for d in 1..=k {
|
||||||
hit = Some(0);
|
|
||||||
}
|
|
||||||
for d in 1..=self.k {
|
|
||||||
let old = r[d];
|
let old = r[d];
|
||||||
r[d] = (((old << 1) | 1) & mask) // extend a ≤d-error state
|
r[d] = (((old << 1) | 1) & mask) // extend a ≤d-error state
|
||||||
| prev_old // insertion in text
|
| prev_old // insertion in text
|
||||||
| (prev_old << 1) // substitution
|
| (prev_old << 1) // substitution
|
||||||
| ((r[d - 1] << 1) | 1); // deletion (pattern byte skipped)
|
| ((r[d - 1] << 1) | 1); // deletion (pattern byte skipped)
|
||||||
prev_old = old;
|
prev_old = old;
|
||||||
if hit.is_none() && r[d] & done != 0 {
|
}
|
||||||
hit = Some(d);
|
r[k] & self.done != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `hit` with the end of every match a forward scan finds and the
|
||||||
|
/// registers there, which it may reset; stop when it breaks. The budgets
|
||||||
|
/// default searches use get a copy with the register count fixed, so the
|
||||||
|
/// loop unrolls. Measured against the runtime count, on top of the note
|
||||||
|
/// at [`Bitap::step`]: `~recieve` 307–322 → 195–199 ms, `~serialze`
|
||||||
|
/// 391–398 → 239–240, `~HashMpa` 497–505 → 302–304.
|
||||||
|
fn each_match(
|
||||||
|
&self,
|
||||||
|
hay: &[u8],
|
||||||
|
hit: impl FnMut(usize, &mut [u64; MAX_REGISTERS]) -> ControlFlow<()>,
|
||||||
|
) {
|
||||||
|
match self.k {
|
||||||
|
1 => self.scan::<1>(hay, hit),
|
||||||
|
2 => self.scan::<2>(hay, hit),
|
||||||
|
_ => self.scan::<RUNTIME_K>(hay, hit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan<const K: usize>(
|
||||||
|
&self,
|
||||||
|
hay: &[u8],
|
||||||
|
mut hit: impl FnMut(usize, &mut [u64; MAX_REGISTERS]) -> ControlFlow<()>,
|
||||||
|
) {
|
||||||
|
let mut r = [0u64; MAX_REGISTERS];
|
||||||
|
self.reset(&mut r);
|
||||||
|
for (i, &b) in hay.iter().enumerate() {
|
||||||
|
if self.step::<K>(&self.masks, &mut r, b) && hit(i + 1, &mut r).is_break() {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hit
|
}
|
||||||
|
|
||||||
|
/// The fewest errors the match [`Bitap::step`] just reported has.
|
||||||
|
fn errors(&self, r: &[u64; MAX_REGISTERS]) -> usize {
|
||||||
|
(0..=self.k)
|
||||||
|
.find(|&d| r[d] & self.done != 0)
|
||||||
|
.unwrap_or(self.k)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the match that ended at `end` with `errors` edits began: the same
|
/// Where the match that ended at `end` with `errors` edits began: the same
|
||||||
|
|
@ -95,10 +148,8 @@ impl Bitap {
|
||||||
let mut r = [0u64; MAX_REGISTERS];
|
let mut r = [0u64; MAX_REGISTERS];
|
||||||
self.reset(&mut r);
|
self.reset(&mut r);
|
||||||
for (back, &b) in hay[floor..end].iter().rev().enumerate() {
|
for (back, &b) in hay[floor..end].iter().rev().enumerate() {
|
||||||
if self
|
self.step::<RUNTIME_K>(&self.rev_masks, &mut r, b);
|
||||||
.step(&self.rev_masks, &mut r, b)
|
if r[errors] & self.done != 0 {
|
||||||
.is_some_and(|d| d <= errors)
|
|
||||||
{
|
|
||||||
return end - (back + 1);
|
return end - (back + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +172,8 @@ impl Bitap {
|
||||||
let mut best = (errors, end);
|
let mut best = (errors, end);
|
||||||
let mut probe = *r;
|
let mut probe = *r;
|
||||||
for (ahead, &b) in hay[end..].iter().take(errors).enumerate() {
|
for (ahead, &b) in hay[end..].iter().take(errors).enumerate() {
|
||||||
if let Some(d) = self.step(&self.masks, &mut probe, b) {
|
if self.step::<RUNTIME_K>(&self.masks, &mut probe, b) {
|
||||||
|
let d = self.errors(&probe);
|
||||||
if d < best.0 {
|
if d < best.0 {
|
||||||
best = (d, end + ahead + 1);
|
best = (d, end + ahead + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -133,21 +185,18 @@ impl Bitap {
|
||||||
/// The smallest edit distance (≤ k) at which the pattern occurs in `hay`,
|
/// The smallest edit distance (≤ k) at which the pattern occurs in `hay`,
|
||||||
/// and that occurrence's byte range: the span a frontend marks.
|
/// and that occurrence's byte range: the span a frontend marks.
|
||||||
pub fn best_distance_and_first(&self, hay: &[u8]) -> Option<(usize, (usize, usize))> {
|
pub fn best_distance_and_first(&self, hay: &[u8]) -> Option<(usize, (usize, usize))> {
|
||||||
let mut r = [0u64; MAX_REGISTERS];
|
|
||||||
self.reset(&mut r);
|
|
||||||
let mut best: Option<(usize, usize)> = None;
|
let mut best: Option<(usize, usize)> = None;
|
||||||
for (i, &b) in hay.iter().enumerate() {
|
self.each_match(hay, |end, r| {
|
||||||
if let Some(d) = self.step(&self.masks, &mut r, b) {
|
let d = self.errors(r);
|
||||||
let end = i + 1;
|
if best.is_none_or(|(cur, _)| d < cur) {
|
||||||
if d == 0 {
|
best = Some((d, end));
|
||||||
best = Some((0, end));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if best.is_none_or(|(cur, _)| d < cur) {
|
|
||||||
best = Some((d, end));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if d == 0 {
|
||||||
|
ControlFlow::Break(())
|
||||||
|
} else {
|
||||||
|
ControlFlow::Continue(())
|
||||||
|
}
|
||||||
|
});
|
||||||
best.map(|(d, end)| (d, (self.match_start(hay, end, d), end)))
|
best.map(|(d, end)| (d, (self.match_start(hay, end, d), end)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,20 +204,17 @@ impl Bitap {
|
||||||
/// one's byte range in `hay`. After each hit the automaton resets, so
|
/// one's byte range in `hay`. After each hit the automaton resets, so
|
||||||
/// overlapping suffix matches don't inflate counts.
|
/// overlapping suffix matches don't inflate counts.
|
||||||
pub fn count_and_first(&self, hay: &[u8]) -> (usize, Option<(usize, usize)>) {
|
pub fn count_and_first(&self, hay: &[u8]) -> (usize, Option<(usize, usize)>) {
|
||||||
let mut r = [0u64; MAX_REGISTERS];
|
|
||||||
self.reset(&mut r);
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
let mut first: Option<(usize, usize)> = None;
|
let mut first: Option<(usize, usize)> = None;
|
||||||
for (i, &b) in hay.iter().enumerate() {
|
self.each_match(hay, |end, r| {
|
||||||
if let Some(d) = self.step(&self.masks, &mut r, b) {
|
count += 1;
|
||||||
count += 1;
|
if first.is_none() {
|
||||||
if first.is_none() {
|
let (errors, end) = self.refine_end(hay, r, end, self.errors(r));
|
||||||
let (errors, end) = self.refine_end(hay, &r, i + 1, d);
|
first = Some((self.match_start(hay, end, errors), end));
|
||||||
first = Some((self.match_start(hay, end, errors), end));
|
|
||||||
}
|
|
||||||
self.reset(&mut r);
|
|
||||||
}
|
}
|
||||||
}
|
self.reset(r);
|
||||||
|
ControlFlow::Continue(())
|
||||||
|
});
|
||||||
(count, first)
|
(count, first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +563,35 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The budgets with a fixed register count must report exactly the
|
||||||
|
/// matches, and the errors at each, that the runtime-count loop does.
|
||||||
|
#[test]
|
||||||
|
fn fixed_register_counts_agree_with_the_runtime_loop() {
|
||||||
|
fn matches(b: &Bitap, hay: &[u8], fixed: bool) -> Vec<(usize, usize)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let hit = |end, r: &mut [u64; MAX_REGISTERS]| {
|
||||||
|
out.push((end, b.errors(r)));
|
||||||
|
b.reset(r);
|
||||||
|
ControlFlow::Continue(())
|
||||||
|
};
|
||||||
|
if fixed {
|
||||||
|
b.each_match(hay, hit);
|
||||||
|
} else {
|
||||||
|
b.scan::<RUNTIME_K>(hay, hit);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
let hay = b"the quick brown fox jumps over the lazy dog; The Quikc Browm Fx";
|
||||||
|
for pattern in ["quick", "brown", "fox", "lazy dog", "jumps", "QUICK"] {
|
||||||
|
for k in 1..=2 {
|
||||||
|
let b = Bitap::new(pattern.as_bytes(), k).unwrap();
|
||||||
|
let fixed = matches(&b, hay, true);
|
||||||
|
assert!(!fixed.is_empty(), "{pattern} occurs at k={k}");
|
||||||
|
assert_eq!(fixed, matches(&b, hay, false), "{pattern} k={k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_brute_force_oracle() {
|
fn matches_brute_force_oracle() {
|
||||||
let mut seed: u64 = 0x2545F4914F6CDD1D;
|
let mut seed: u64 = 0x2545F4914F6CDD1D;
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,6 @@ impl SearchHit {
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SearchUpdate {
|
pub enum SearchUpdate {
|
||||||
Started {
|
|
||||||
generation: u64,
|
|
||||||
},
|
|
||||||
Hits {
|
Hits {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
hits: Vec<SearchHit>,
|
hits: Vec<SearchHit>,
|
||||||
|
|
@ -121,8 +118,7 @@ pub enum SearchUpdate {
|
||||||
impl SearchUpdate {
|
impl SearchUpdate {
|
||||||
pub fn generation(&self) -> u64 {
|
pub fn generation(&self) -> u64 {
|
||||||
match self {
|
match self {
|
||||||
SearchUpdate::Started { generation }
|
SearchUpdate::Hits { generation, .. }
|
||||||
| SearchUpdate::Hits { generation, .. }
|
|
||||||
| SearchUpdate::Completed { generation, .. }
|
| SearchUpdate::Completed { generation, .. }
|
||||||
| SearchUpdate::Error { generation, .. } => *generation,
|
| SearchUpdate::Error { generation, .. } => *generation,
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +405,6 @@ impl Worker {
|
||||||
|
|
||||||
fn handle(&mut self, req: SearchRequest) {
|
fn handle(&mut self, req: SearchRequest) {
|
||||||
let generation = req.generation;
|
let generation = req.generation;
|
||||||
self.send(SearchUpdate::Started { generation });
|
|
||||||
match self.search(&req) {
|
match self.search(&req) {
|
||||||
Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed {
|
Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed {
|
||||||
generation,
|
generation,
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,6 @@
|
||||||
//! markup. Matching folds ASCII case only, as full-text verification does:
|
//! markup. Matching folds ASCII case only, as full-text verification does:
|
||||||
//! `cafe` neither finds nor marks `café`.
|
//! `cafe` neither finds nor marks `café`.
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Options {
|
|
||||||
/// Approximate byte budget for the returned window: a soft target, since
|
|
||||||
/// matches expand the window so a hit is never cut off.
|
|
||||||
pub approx_bytes: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Options {
|
|
||||||
fn default() -> Self {
|
|
||||||
Options { approx_bytes: 200 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A context window from a document plus the match positions inside it.
|
/// A context window from a document plus the match positions inside it.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Snippet {
|
pub struct Snippet {
|
||||||
|
|
@ -40,35 +27,46 @@ impl Snippet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract against a haystack the caller has already ASCII-folded. `folded`
|
/// What [`extract_folded`] found in one body.
|
||||||
/// must be `text.to_ascii_lowercase()`: the fold preserves byte length, so
|
#[derive(Debug, Clone)]
|
||||||
/// offsets found in it slice the original.
|
pub struct Found {
|
||||||
|
pub snippet: Snippet,
|
||||||
|
/// Occurrences in any ASCII case, counted before touching ones were
|
||||||
|
/// coalesced: what the full-text pass ranks on.
|
||||||
|
pub count: usize,
|
||||||
|
/// How many of those are spelled exactly as typed.
|
||||||
|
pub exact: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find `term` in a haystack the caller has already ASCII-folded, and cut the
|
||||||
|
/// window around its first occurrence. `folded` must be
|
||||||
|
/// `text.to_ascii_lowercase()` and `term` must be `typed` ASCII-lowercased:
|
||||||
|
/// the fold preserves byte length, so an offset found in `folded` slices
|
||||||
|
/// `text`, where the occurrence is compared with `typed`. `None` when the
|
||||||
|
/// term does not occur; a blank term never does, but a padded one is searched
|
||||||
|
/// as given, never trimmed.
|
||||||
///
|
///
|
||||||
/// Returns the window and how many occurrences of `term` it found before
|
/// `exact` comes from the case-insensitive occurrences rather than a second,
|
||||||
/// touching ranges were coalesced, the count the full-text pass ranks on. A
|
/// case-sensitive search: the same count for any term that cannot overlap
|
||||||
/// blank term matches nothing, but a padded term is searched as given, never
|
/// itself, and at most one short per overlap for one that can (`aA` in `aaA`).
|
||||||
/// trimmed.
|
pub fn extract_folded(
|
||||||
pub fn extract_folded(text: &str, folded: &str, term: &str, opts: &Options) -> (Snippet, usize) {
|
text: &str,
|
||||||
|
folded: &str,
|
||||||
|
term: &str,
|
||||||
|
typed: &str,
|
||||||
|
budget: usize,
|
||||||
|
) -> Option<Found> {
|
||||||
debug_assert_eq!(folded.len(), text.len(), "ASCII folding preserves length");
|
debug_assert_eq!(folded.len(), text.len(), "ASCII folding preserves length");
|
||||||
if text.is_empty() {
|
debug_assert_eq!(term, typed.to_ascii_lowercase(), "`term` is `typed` folded");
|
||||||
return (Snippet::empty(), 0);
|
|
||||||
}
|
|
||||||
if term.trim().is_empty() {
|
if term.trim().is_empty() {
|
||||||
return (head_window(text, opts.approx_bytes), 0);
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Borrow when the term is already folded, as every cascade call's is,
|
|
||||||
// to avoid an allocation per candidate row.
|
|
||||||
let pattern: std::borrow::Cow<'_, str> = if term.bytes().any(|b| b.is_ascii_uppercase()) {
|
|
||||||
std::borrow::Cow::Owned(term.to_ascii_lowercase())
|
|
||||||
} else {
|
|
||||||
std::borrow::Cow::Borrowed(term)
|
|
||||||
};
|
|
||||||
// `memmem`, not `str::match_indices`: measured faster on whole-body
|
// `memmem`, not `str::match_indices`: measured faster on whole-body
|
||||||
// scans. The walk runs to the end (the count must see every occurrence),
|
// scans. The walk runs to the end (the counts must see every occurrence),
|
||||||
// but matches past the window's right edge are not stored.
|
// but matches past the window's right edge are not stored.
|
||||||
let mut matches: Vec<(usize, usize)> = Vec::new();
|
let mut matches: Vec<(usize, usize)> = Vec::new();
|
||||||
let mut found = 0usize;
|
let (mut count, mut exact) = (0usize, 0usize);
|
||||||
// The window's right edge once the first match fixes it, from the same
|
// The window's right edge once the first match fixes it, from the same
|
||||||
// [`window_bounds`] the window itself uses.
|
// [`window_bounds`] the window itself uses.
|
||||||
let mut keep_below: Option<usize> = None;
|
let mut keep_below: Option<usize> = None;
|
||||||
|
|
@ -76,29 +74,32 @@ pub fn extract_folded(text: &str, folded: &str, term: &str, opts: &Options) -> (
|
||||||
// (`abab…` for `ab`) that starts inside the window and continues past
|
// (`abab…` for `ab`) that starts inside the window and continues past
|
||||||
// it stays intact for `coalesce_overlapping` and the expansion step.
|
// it stays intact for `coalesce_overlapping` and the expansion step.
|
||||||
let mut chain_end = 0usize;
|
let mut chain_end = 0usize;
|
||||||
for at in memchr::memmem::find_iter(folded.as_bytes(), pattern.as_bytes()) {
|
for at in memchr::memmem::find_iter(folded.as_bytes(), term.as_bytes()) {
|
||||||
// Before any dropping: two abutting occurrences are two hits for
|
// Before any dropping: two abutting occurrences are two hits for
|
||||||
// ranking even though they paint as one highlight.
|
// ranking even though they paint as one highlight.
|
||||||
found += 1;
|
count += 1;
|
||||||
let bound = *keep_below.get_or_insert_with(|| window_bounds(text, at, opts.approx_bytes).1);
|
if text.as_bytes()[at..at + term.len()] == *typed.as_bytes() {
|
||||||
|
exact += 1;
|
||||||
|
}
|
||||||
|
let bound = *keep_below.get_or_insert_with(|| window_bounds(text, at, budget).1);
|
||||||
if at >= bound && at > chain_end {
|
if at >= bound && at > chain_end {
|
||||||
// Past the edge and not chained to anything kept: keep counting,
|
// Past the edge and not chained to anything kept: keep counting,
|
||||||
// stop storing.
|
// stop storing.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
chain_end = at + pattern.len();
|
chain_end = at + term.len();
|
||||||
matches.push((at, chain_end));
|
matches.push((at, chain_end));
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
return (head_window(text, opts.approx_bytes), 0);
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One term's occurrences arrive in order and never overlap; only
|
// One term's occurrences arrive in order and never overlap; only
|
||||||
// abutting ones merge.
|
// abutting ones merge.
|
||||||
let matches = coalesce_overlapping(matches);
|
let matches = coalesce_overlapping(matches);
|
||||||
|
|
||||||
let (win_start, mut win_end) = window_bounds(text, matches[0].0, opts.approx_bytes);
|
let (win_start, mut win_end) = window_bounds(text, matches[0].0, budget);
|
||||||
// A match straddling the right edge is fully included, not cut mid-hit.
|
// A match straddling the right edge is fully included, not cut mid-hit.
|
||||||
if let Some((_, end)) = matches.iter().rfind(|(s, _)| *s < win_end) {
|
if let Some((_, end)) = matches.iter().rfind(|(s, _)| *s < win_end) {
|
||||||
if *end > win_end {
|
if *end > win_end {
|
||||||
|
|
@ -117,15 +118,16 @@ pub fn extract_folded(text: &str, folded: &str, term: &str, opts: &Options) -> (
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
(
|
Some(Found {
|
||||||
Snippet {
|
snippet: Snippet {
|
||||||
window: text[win_start..win_end].to_string(),
|
window: text[win_start..win_end].to_string(),
|
||||||
ranges,
|
ranges,
|
||||||
truncated_start: win_start > 0,
|
truncated_start: win_start > 0,
|
||||||
truncated_end: win_end < text.len(),
|
truncated_end: win_end < text.len(),
|
||||||
},
|
},
|
||||||
found,
|
count,
|
||||||
)
|
exact,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The window cut around a match that starts at `first`: from a third of
|
/// The window cut around a match that starts at `first`: from a third of
|
||||||
|
|
@ -174,12 +176,12 @@ pub fn whole_field(text: &str, range: (usize, usize)) -> Snippet {
|
||||||
|
|
||||||
/// Build a snippet window around one known match range in `text`. The range
|
/// Build a snippet window around one known match range in `text`. The range
|
||||||
/// is clamped into `text` and widened to char boundaries.
|
/// is clamped into `text` and widened to char boundaries.
|
||||||
pub fn window_around(text: &str, range: (usize, usize), opts: &Options) -> Snippet {
|
pub fn window_around(text: &str, range: (usize, usize), budget: usize) -> Snippet {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return Snippet::empty();
|
return Snippet::empty();
|
||||||
}
|
}
|
||||||
let (ms, me) = aligned_range(text, range);
|
let (ms, me) = aligned_range(text, range);
|
||||||
let (win_start, win_end) = window_bounds(text, ms, opts.approx_bytes);
|
let (win_start, win_end) = window_bounds(text, ms, budget);
|
||||||
// The whole match stays inside the window.
|
// The whole match stays inside the window.
|
||||||
let win_end = win_end.max(me);
|
let win_end = win_end.max(me);
|
||||||
|
|
||||||
|
|
@ -196,26 +198,6 @@ pub fn window_around(text: &str, range: (usize, usize), opts: &Options) -> Snipp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count non-overlapping occurrences of `term` in `text`, byte for byte: a
|
|
||||||
/// case-insensitive count passes both already folded.
|
|
||||||
pub fn count_occurrences(text: &str, term: &str) -> usize {
|
|
||||||
if term.is_empty() || text.len() < term.len() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
memchr::memmem::find_iter(text.as_bytes(), term.as_bytes()).count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The first `n` bytes of `text` (char-aligned) as a match-less window.
|
|
||||||
fn head_window(text: &str, n: usize) -> Snippet {
|
|
||||||
let cut = text.floor_char_boundary(n);
|
|
||||||
Snippet {
|
|
||||||
window: text[..cut].to_string(),
|
|
||||||
ranges: Vec::new(),
|
|
||||||
truncated_start: false,
|
|
||||||
truncated_end: cut < text.len(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge adjacent/overlapping ranges. Input must be sorted by start.
|
/// Merge adjacent/overlapping ranges. Input must be sorted by start.
|
||||||
fn coalesce_overlapping(v: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
|
fn coalesce_overlapping(v: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
|
||||||
let mut out = Vec::with_capacity(v.len());
|
let mut out = Vec::with_capacity(v.len());
|
||||||
|
|
@ -239,80 +221,87 @@ fn coalesce_overlapping(v: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn extract(text: &str, term: &str, opts: &Options) -> Snippet {
|
/// [`extract_folded`] with the folding done here; `typed` as given.
|
||||||
extract_folded(text, &text.to_ascii_lowercase(), term, opts).0
|
fn find(text: &str, typed: &str, budget: usize) -> Option<Found> {
|
||||||
|
let term = typed.to_ascii_lowercase();
|
||||||
|
extract_folded(text, &text.to_ascii_lowercase(), &term, typed, budget)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_counted(text: &str, term: &str, opts: &Options) -> (Snippet, usize) {
|
fn extract(text: &str, term: &str, budget: usize) -> Snippet {
|
||||||
extract_folded(text, &text.to_ascii_lowercase(), term, opts)
|
find(text, term, budget).expect("the term occurs").snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
fn opts_small() -> Options {
|
fn extract_counted(text: &str, term: &str, budget: usize) -> (Snippet, usize) {
|
||||||
Options { approx_bytes: 40 }
|
let found = find(text, term, budget).expect("the term occurs");
|
||||||
|
(found.snippet, found.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Non-overlapping occurrences, byte for byte.
|
||||||
|
fn memmem_count(text: &str, term: &str) -> usize {
|
||||||
|
memchr::memmem::find_iter(text.as_bytes(), term.as_bytes()).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A window budget small enough that tests see truncation.
|
||||||
|
const SMALL: usize = 40;
|
||||||
|
|
||||||
/// The count is occurrences, not the highlights they coalesce into.
|
/// The count is occurrences, not the highlights they coalesce into.
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_folded_counts_occurrences_not_ranges() {
|
fn extract_folded_counts_occurrences_not_ranges() {
|
||||||
let text = "abab and ab";
|
let text = "abab and ab";
|
||||||
let (s, n) = extract_counted(text, "ab", &opts_small());
|
let (s, n) = extract_counted(text, "ab", SMALL);
|
||||||
assert_eq!(n, 3, "three occurrences");
|
assert_eq!(n, 3, "three occurrences");
|
||||||
assert_eq!(
|
assert_eq!(n, memmem_count(text, "ab"));
|
||||||
n,
|
|
||||||
count_occurrences(text, "ab"),
|
|
||||||
"must agree with count_occurrences"
|
|
||||||
);
|
|
||||||
assert_eq!(s.ranges.len(), 2, "the abutting pair paints as one range");
|
assert_eq!(s.ranges.len(), 2, "the abutting pair paints as one range");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A disagreement here would change which rows survive the pass.
|
/// A disagreement here would change which rows survive the pass, or
|
||||||
|
/// rank them: both counts must match separate searches, one over the
|
||||||
|
/// folded body and one over the body as stored.
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_folded_count_agrees_with_count_occurrences() {
|
fn extract_folded_counts_agree_with_separate_searches() {
|
||||||
let cases: &[(&str, &str)] = &[
|
let cases: &[(&str, &str)] = &[
|
||||||
("no hits at all", "zzz"),
|
|
||||||
("one hit here", "hit"),
|
("one hit here", "hit"),
|
||||||
("Hit hit HIT", "hit"),
|
("Hit hit HIT", "hit"),
|
||||||
|
("Hit hit HIT", "HIT"),
|
||||||
|
("Hit hit HIT", "hIT"),
|
||||||
("aaaa", "aa"),
|
("aaaa", "aa"),
|
||||||
("", "x"),
|
("ünïcode ÜNÏCODE ünïcode", "ünïcode"),
|
||||||
("short", "much longer than the haystack"),
|
("Needle and needle and NEEDLE", "Needle"),
|
||||||
("ünïcode ünïcode", "ünïcode"),
|
|
||||||
];
|
];
|
||||||
for (text, term) in cases {
|
for (text, typed) in cases {
|
||||||
|
let found = find(text, typed, SMALL).expect("the term occurs");
|
||||||
let folded = text.to_ascii_lowercase();
|
let folded = text.to_ascii_lowercase();
|
||||||
let (_, n) = extract_folded(text, &folded, term, &opts_small());
|
let term = typed.to_ascii_lowercase();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
n,
|
found.count,
|
||||||
count_occurrences(&folded, &term.to_ascii_lowercase()),
|
memmem_count(&folded, &term),
|
||||||
"count mismatch for {:?} in {:?}",
|
"{typed:?} in {text:?}"
|
||||||
term,
|
);
|
||||||
text
|
assert_eq!(
|
||||||
|
found.exact,
|
||||||
|
memmem_count(text, typed),
|
||||||
|
"{typed:?} in {text:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The owning and borrowing branches of the fold must find the same
|
|
||||||
/// matches.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_folded_needle_case_does_not_change_matches() {
|
fn a_term_that_does_not_occur_finds_nothing() {
|
||||||
let text = "The Needle and the needle";
|
for (text, term) in [
|
||||||
let folded = text.to_ascii_lowercase();
|
("no hits at all", "zzz"),
|
||||||
let (upper, n_upper) = extract_folded(text, &folded, "Needle", &opts_small());
|
("", "x"),
|
||||||
let (lower, n_lower) = extract_folded(text, &folded, "needle", &opts_small());
|
("short", "much longer than the haystack"),
|
||||||
assert_eq!(n_upper, 2);
|
("alpha beta gamma", "nomatch"),
|
||||||
assert_eq!(n_upper, n_lower);
|
] {
|
||||||
assert_eq!(upper.ranges, lower.ranges);
|
assert!(find(text, term, SMALL).is_none(), "{term:?} in {text:?}");
|
||||||
assert_eq!(upper.window, lower.window);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty needle would match at every byte offset.
|
/// An empty needle would match at every byte offset.
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_folded_ignores_blank_terms() {
|
fn extract_folded_ignores_blank_terms() {
|
||||||
let text = "some text";
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
for term in ["", " ", "\t"] {
|
for term in ["", " ", "\t"] {
|
||||||
let (s, n) = extract_folded(text, &folded, term, &opts_small());
|
assert!(find("some text", term, SMALL).is_none(), "{term:?}");
|
||||||
assert_eq!(n, 0, "blank term {:?} counted", term);
|
|
||||||
assert!(s.ranges.is_empty(), "blank term {:?} highlighted", term);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,10 +309,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_folded_does_not_trim_a_padded_term() {
|
fn extract_folded_does_not_trim_a_padded_term() {
|
||||||
let text = "needle needlework";
|
let text = "needle needlework";
|
||||||
let folded = text.to_ascii_lowercase();
|
let (_, n) = extract_counted(text, "needle ", SMALL);
|
||||||
let (_, n) = extract_folded(text, &folded, "needle ", &opts_small());
|
|
||||||
assert_eq!(n, 1, "only the occurrence followed by a space");
|
assert_eq!(n, 1, "only the occurrence followed by a space");
|
||||||
assert_eq!(n, count_occurrences(&folded, "needle "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `Snippet::ranges` contract egui's LayoutJob sections rely on.
|
/// The `Snippet::ranges` contract egui's LayoutJob sections rely on.
|
||||||
|
|
@ -349,7 +336,7 @@ mod tests {
|
||||||
fn occurrences_past_the_window_are_counted_but_not_kept() {
|
fn occurrences_past_the_window_are_counted_but_not_kept() {
|
||||||
let unit = "needle filler filler ";
|
let unit = "needle filler filler ";
|
||||||
let text = unit.repeat(400);
|
let text = unit.repeat(400);
|
||||||
let (snip, found) = extract_counted(&text, "needle", &opts_small());
|
let (snip, found) = extract_counted(&text, "needle", SMALL);
|
||||||
|
|
||||||
assert_eq!(found, 400, "every occurrence must still be counted");
|
assert_eq!(found, 400, "every occurrence must still be counted");
|
||||||
assert_ranges_valid(&snip);
|
assert_ranges_valid(&snip);
|
||||||
|
|
@ -378,7 +365,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_coalescing_chain_is_not_cut_at_the_window_edge() {
|
fn a_coalescing_chain_is_not_cut_at_the_window_edge() {
|
||||||
let text = "ab".repeat(400);
|
let text = "ab".repeat(400);
|
||||||
let (snip, found) = extract_counted(&text, "ab", &opts_small());
|
let (snip, found) = extract_counted(&text, "ab", SMALL);
|
||||||
|
|
||||||
assert_eq!(found, 400, "non-overlapping occurrences, all counted");
|
assert_eq!(found, 400, "non-overlapping occurrences, all counted");
|
||||||
assert_ranges_valid(&snip);
|
assert_ranges_valid(&snip);
|
||||||
|
|
@ -403,46 +390,23 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_text_returns_empty() {
|
|
||||||
let s = extract("", "foo", &Options::default());
|
|
||||||
assert_eq!(s, Snippet::empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_blank_term_returns_head_marked_truncated() {
|
|
||||||
let long = "abcdefghijklmnop".repeat(10);
|
|
||||||
let s = extract(&long, "", &opts_small());
|
|
||||||
assert!(s.truncated_end);
|
|
||||||
assert!(!s.truncated_start);
|
|
||||||
assert!(s.ranges.is_empty());
|
|
||||||
assert!(s.window.len() <= 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_blank_term_untruncated() {
|
|
||||||
let s = extract("short text", "", &opts_small());
|
|
||||||
assert_eq!(s.window, "short text");
|
|
||||||
assert!(!s.truncated_end && !s.truncated_start);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn simple_match_range() {
|
fn simple_match_range() {
|
||||||
let s = extract("the quick brown fox", "quick", &opts_small());
|
let s = extract("the quick brown fox", "quick", SMALL);
|
||||||
assert_eq!(marked(&s), vec!["quick"]);
|
assert_eq!(marked(&s), vec!["quick"]);
|
||||||
assert_ranges_valid(&s);
|
assert_ranges_valid(&s);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn case_insensitive_match_reports_original_case() {
|
fn case_insensitive_match_reports_original_case() {
|
||||||
let s = extract("The QUICK brown fox", "quick", &opts_small());
|
let s = extract("The QUICK brown fox", "quick", SMALL);
|
||||||
assert_eq!(marked(&s), vec!["QUICK"]);
|
assert_eq!(marked(&s), vec!["QUICK"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_truncation_flags_on_both_sides() {
|
fn window_truncation_flags_on_both_sides() {
|
||||||
let text = "prefix ".repeat(20) + "MATCH in middle " + &"suffix ".repeat(20);
|
let text = "prefix ".repeat(20) + "MATCH in middle " + &"suffix ".repeat(20);
|
||||||
let s = extract(&text, "MATCH", &opts_small());
|
let s = extract(&text, "MATCH", SMALL);
|
||||||
assert!(s.truncated_start);
|
assert!(s.truncated_start);
|
||||||
assert!(s.truncated_end);
|
assert!(s.truncated_end);
|
||||||
assert_eq!(marked(&s), vec!["MATCH"]);
|
assert_eq!(marked(&s), vec!["MATCH"]);
|
||||||
|
|
@ -450,27 +414,15 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn match_at_start_not_truncated_left() {
|
fn match_at_start_not_truncated_left() {
|
||||||
let s = extract(
|
let s = extract("MATCH right at the start of this paragraph", "match", SMALL);
|
||||||
"MATCH right at the start of this paragraph",
|
|
||||||
"match",
|
|
||||||
&opts_small(),
|
|
||||||
);
|
|
||||||
assert!(!s.truncated_start);
|
assert!(!s.truncated_start);
|
||||||
assert_eq!(s.ranges[0].0, 0);
|
assert_eq!(s.ranges[0].0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_match_returns_head_without_ranges() {
|
|
||||||
let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa";
|
|
||||||
let s = extract(text, "nomatch", &opts_small());
|
|
||||||
assert!(s.ranges.is_empty());
|
|
||||||
assert!(s.window.starts_with("alpha"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn utf8_boundaries_hold_with_multibyte_text() {
|
fn utf8_boundaries_hold_with_multibyte_text() {
|
||||||
let text = "café café café café café café café café café café";
|
let text = "café café café café café café café café café café";
|
||||||
let s = extract(text, "café", &opts_small());
|
let s = extract(text, "café", SMALL);
|
||||||
assert!(!s.ranges.is_empty());
|
assert!(!s.ranges.is_empty());
|
||||||
assert_ranges_valid(&s);
|
assert_ranges_valid(&s);
|
||||||
for m in marked(&s) {
|
for m in marked(&s) {
|
||||||
|
|
@ -482,7 +434,7 @@ mod tests {
|
||||||
fn match_near_right_edge_is_fully_included() {
|
fn match_near_right_edge_is_fully_included() {
|
||||||
let prefix = "x".repeat(30);
|
let prefix = "x".repeat(30);
|
||||||
let text = format!("{}{}", prefix, "LONGMATCHTERMTEXT");
|
let text = format!("{}{}", prefix, "LONGMATCHTERMTEXT");
|
||||||
let s = extract(&text, "LONGMATCHTERMTEXT", &opts_small());
|
let s = extract(&text, "LONGMATCHTERMTEXT", SMALL);
|
||||||
assert_eq!(marked(&s), vec!["LONGMATCHTERMTEXT"]);
|
assert_eq!(marked(&s), vec!["LONGMATCHTERMTEXT"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,7 +442,7 @@ mod tests {
|
||||||
fn window_around_basic() {
|
fn window_around_basic() {
|
||||||
let text = "prefix ".repeat(20) + "NEEDLE" + &" suffix".repeat(20);
|
let text = "prefix ".repeat(20) + "NEEDLE" + &" suffix".repeat(20);
|
||||||
let at = text.find("NEEDLE").unwrap();
|
let at = text.find("NEEDLE").unwrap();
|
||||||
let s = window_around(&text, (at, at + 6), &opts_small());
|
let s = window_around(&text, (at, at + 6), SMALL);
|
||||||
assert_eq!(marked(&s), vec!["NEEDLE"]);
|
assert_eq!(marked(&s), vec!["NEEDLE"]);
|
||||||
assert!(s.truncated_start && s.truncated_end);
|
assert!(s.truncated_start && s.truncated_end);
|
||||||
assert_ranges_valid(&s);
|
assert_ranges_valid(&s);
|
||||||
|
|
@ -498,28 +450,18 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_around_clamps_out_of_bounds() {
|
fn window_around_clamps_out_of_bounds() {
|
||||||
let s = window_around("tiny", (2, 999), &opts_small());
|
let s = window_around("tiny", (2, 999), SMALL);
|
||||||
assert_eq!(s.window, "tiny");
|
assert_eq!(s.window, "tiny");
|
||||||
assert_eq!(s.ranges, vec![(2, 4)]);
|
assert_eq!(s.ranges, vec![(2, 4)]);
|
||||||
// Fully out-of-range → no ranges, but never a panic.
|
// Fully out-of-range → no ranges, but never a panic.
|
||||||
let s = window_around("tiny", (999, 1000), &opts_small());
|
let s = window_around("tiny", (999, 1000), SMALL);
|
||||||
assert!(s.ranges.is_empty());
|
assert!(s.ranges.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_around_aligns_multibyte_boundaries() {
|
fn window_around_aligns_multibyte_boundaries() {
|
||||||
let text = "ééééééééé needle ééééééééé";
|
let text = "ééééééééé needle ééééééééé";
|
||||||
let s = window_around(text, (1, 3), &opts_small());
|
let s = window_around(text, (1, 3), SMALL);
|
||||||
assert_ranges_valid(&s);
|
assert_ranges_valid(&s);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn count_occurrences_cases() {
|
|
||||||
assert_eq!(count_occurrences("aaaa", "aaa"), 1, "non-overlapping");
|
|
||||||
assert_eq!(count_occurrences("abcABC", "abc"), 1, "byte for byte");
|
|
||||||
assert_eq!(count_occurrences("", "x"), 0);
|
|
||||||
assert_eq!(count_occurrences("xyz", ""), 0);
|
|
||||||
assert_eq!(count_occurrences("no hits here", "zzz"), 0);
|
|
||||||
assert_eq!(count_occurrences("ab ab ab", "ab"), 3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,8 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
|
// As a finished run's maintenance leaves it.
|
||||||
|
crate::db::repo::build_hash_index(&conn).unwrap();
|
||||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ use std::thread::JoinHandle;
|
||||||
use crate::config::{Config, IgnoreSet};
|
use crate::config::{Config, IgnoreSet};
|
||||||
use crate::extract::Registry;
|
use crate::extract::Registry;
|
||||||
use crate::file_handling::{
|
use crate::file_handling::{
|
||||||
classify_by_mtime, classify_for_indexing, dir_to_db_parent, path_to_db_string,
|
classify_by_mtime, dir_to_db_parent, path_to_db_string, prepare_file_record, DirRows,
|
||||||
prepare_file_record, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
|
FileIndexAction, OwnedNewFile, UnreadableDirs,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod pool;
|
mod pool;
|
||||||
|
|
@ -30,6 +30,10 @@ use pool::{Found, PrefetchWork, Queue, Shared};
|
||||||
/// Files one worker takes for itself before handing the rest to the pool.
|
/// Files one worker takes for itself before handing the rest to the pool.
|
||||||
const FILES_PER_JOB: usize = 128;
|
const FILES_PER_JOB: usize = 128;
|
||||||
|
|
||||||
|
/// Events a root's walk may run ahead of the writer. Each can carry up to
|
||||||
|
/// `hash_length` of inline text, but the run's peak is set during extraction:
|
||||||
|
/// measured at 512 (`memprobe cold`, `/usr`, 373k files), peak RSS 343–355
|
||||||
|
/// against 293–309 MiB here and the walk no faster. Measured and rejected.
|
||||||
const CHANNEL_CAP: usize = 4096;
|
const CHANNEL_CAP: usize = 4096;
|
||||||
|
|
||||||
const LOCAL_THREADS: usize = 4;
|
const LOCAL_THREADS: usize = 4;
|
||||||
|
|
@ -77,11 +81,14 @@ pub enum WalkEvent {
|
||||||
struct PendingFile {
|
struct PendingFile {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
cached: Option<crate::platform::CachedMetadata>,
|
cached: Option<crate::platform::CachedMetadata>,
|
||||||
|
/// The stored mtime, taken out of the directory's rows as it was listed;
|
||||||
|
/// `None` when the file has no row yet.
|
||||||
|
stored: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Job {
|
enum Job {
|
||||||
Dir(PathBuf, Arc<DirRows>),
|
Dir(PathBuf, DirRows),
|
||||||
Files(Vec<PendingFile>, Arc<DirRows>),
|
Files(Vec<PendingFile>),
|
||||||
/// A resolved symlink target, with the stored mtime for its own path.
|
/// A resolved symlink target, with the stored mtime for its own path.
|
||||||
Alias(PathBuf, Option<u64>),
|
Alias(PathBuf, Option<u64>),
|
||||||
}
|
}
|
||||||
|
|
@ -146,9 +153,12 @@ pub fn reset_run_warnings() {
|
||||||
/// for the pool, the remaining files come back for this worker, `stale` gets
|
/// for the pool, the remaining files come back for this worker, `stale` gets
|
||||||
/// the paths whose row has no file behind it. A directory that cannot be read
|
/// the paths whose row has no file behind it. A directory that cannot be read
|
||||||
/// returns before reconciling: it must not read as an empty one.
|
/// returns before reconciling: it must not read as an empty one.
|
||||||
|
///
|
||||||
|
/// Each listed file takes its row out of `rows`, carrying the stored mtime
|
||||||
|
/// with it, so what is left afterwards is exactly the stale set.
|
||||||
fn read_directory(
|
fn read_directory(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
rows: &Arc<DirRows>,
|
mut rows: DirRows,
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
found: &mut Vec<Found>,
|
found: &mut Vec<Found>,
|
||||||
stale: &mut Vec<String>,
|
stale: &mut Vec<String>,
|
||||||
|
|
@ -169,17 +179,11 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Every `continue` in the loop must be a genuine "not indexable", or the
|
// Every `continue` in the loop must be a genuine "not indexable", or the
|
||||||
// stale diff below deletes live rows. Only names that have a row matter to
|
// stale diff below deletes live rows: only a name taken out of `rows`
|
||||||
// that diff, so `present` borrows them from `rows`: no allocation per
|
// keeps its row. Measured against marking names present in a set borrowed
|
||||||
// entry, and none at all for a directory with no rows yet. Measured
|
// from `rows` and looking each one up again in `prepare` (`indexprobe
|
||||||
// (`indexprobe cold`, 100k files): 8.2 → 7.2 allocations per file,
|
// warm`, `/usr`, 373k files): 9.3–9.8 → 8.8–9.3 allocations per file,
|
||||||
// churn 42 → 35 MiB; time did not move, being `stat`-bound.
|
// churn 246–257 → 231–240 MiB; time within noise, being `stat`-bound.
|
||||||
let mut present: HashSet<&str> = HashSet::new();
|
|
||||||
let mut mark_present = |name: &str| {
|
|
||||||
if let Some((known, _)) = rows.get_key_value(name) {
|
|
||||||
present.insert(known.as_str());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut unreadable_entry = false;
|
let mut unreadable_entry = false;
|
||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
@ -198,17 +202,22 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = entry.file_name();
|
// The name borrowed from the path, which the entry allocates either
|
||||||
|
// way: `file_name()` would allocate a second copy. With the path's
|
||||||
|
// buffer becoming its `String` in `prepare`, measured (`indexprobe
|
||||||
|
// warm`, `/usr`, 373k files): 9.2–9.6 → 6.6–6.9 allocations per
|
||||||
|
// file, churn 240–247 → 203–210 MiB, user CPU 0.92–1.01 → 0.83–0.92 s.
|
||||||
|
let path = entry.path();
|
||||||
// The walk's one screen for names the index cannot spell (invalid
|
// The walk's one screen for names the index cannot spell (invalid
|
||||||
// UTF-8 on Unix, unpaired UTF-16 surrogates on Windows; see
|
// UTF-8 on Unix, unpaired UTF-16 surrogates on Windows; see
|
||||||
// `path_to_db_string`), skipped by choice. Leaving the entry out of
|
// `path_to_db_string`), skipped by choice. Its row cannot exist: no
|
||||||
// `present` is safe: no stored row can carry such a name.
|
// stored row can carry such a name.
|
||||||
let Some(name) = name.to_str() else {
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
if UNREPRESENTABLE_WARNINGS.allow() {
|
if UNREPRESENTABLE_WARNINGS.allow() {
|
||||||
crate::log_warn!(
|
crate::log_warn!(
|
||||||
"Skipping {:?} (name is not valid UTF-8, so it cannot be stored, hashed \
|
"Skipping {:?} (name is not valid UTF-8, so it cannot be stored, hashed \
|
||||||
or text-indexed)",
|
or text-indexed)",
|
||||||
entry.path()
|
path
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -233,7 +242,7 @@ fn read_directory(
|
||||||
crate::log_info!(
|
crate::log_info!(
|
||||||
"skipping {}: hidden attribute set (enable \"include hidden \
|
"skipping {}: hidden attribute set (enable \"include hidden \
|
||||||
files\" to index it)",
|
files\" to index it)",
|
||||||
entry.path().display()
|
path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -242,13 +251,12 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
// Ignored, or the index's own database and sidecars (see
|
// Ignored, or the index's own database and sidecars (see
|
||||||
// `file_handling::index_file_set`). `continue`, not `skipped`: the
|
// `file_handling::index_file_set`). `continue`, not `skipped`: the
|
||||||
// name stays out of `present`, so old rows for the index fall to the
|
// row stays in `rows`, so old rows for the index fall to the stale
|
||||||
// stale sweep; `skipped` would keep them.
|
// sweep; `skipped` would keep them.
|
||||||
if ctx.ignore.matches_component(name) {
|
if ctx.ignore.matches_component(name) {
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
|
||||||
if ctx.ignore.matches_path_pattern(&path) || ctx.index_files.contains(&path) {
|
if ctx.ignore.matches_path_pattern(&path) || ctx.index_files.contains(&path) {
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -256,13 +264,13 @@ fn read_directory(
|
||||||
|
|
||||||
// `file_type` is the cached `d_type` from the directory read.
|
// `file_type` is the cached `d_type` from the directory read.
|
||||||
match entry.file_type() {
|
match entry.file_type() {
|
||||||
// Not marked present: a name that was a file last run and is a
|
// Row left in `rows`: a name that was a file last run and is a
|
||||||
// directory now should lose its row.
|
// directory now should lose its row.
|
||||||
Ok(ft) if ft.is_dir() => found.push(Found::Dir(path)),
|
Ok(ft) if ft.is_dir() => found.push(Found::Dir(path)),
|
||||||
// Directory and file targets gate together, or the walkers
|
// Directory and file targets gate together, or the walkers
|
||||||
// disagree: `filtered_walk` follows neither kind, so a file target
|
// disagree: `filtered_walk` follows neither kind, so a file target
|
||||||
// followed only here would never update between runs. Never
|
// followed only here would never update between runs. Row left
|
||||||
// marked present: the target's row belongs to its own directory.
|
// in `rows`: the target's row belongs to its own directory.
|
||||||
Ok(ft) if ft.is_symlink() => {
|
Ok(ft) if ft.is_symlink() => {
|
||||||
if ctx.follow_symlinks {
|
if ctx.follow_symlinks {
|
||||||
if let Some(target) = resolve_link(&path, ctx) {
|
if let Some(target) = resolve_link(&path, ctx) {
|
||||||
|
|
@ -271,29 +279,31 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
mark_present(name);
|
let stored = rows.remove(name);
|
||||||
// `None` on Unix and on any reparse point.
|
// `None` on Unix and on any reparse point.
|
||||||
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
|
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
|
||||||
files.push(PendingFile { path, cached });
|
files.push(PendingFile {
|
||||||
|
path,
|
||||||
|
cached,
|
||||||
|
stored,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Type unknown: taken out so an existing row survives.
|
||||||
|
Err(_) => {
|
||||||
|
rows.remove(name);
|
||||||
}
|
}
|
||||||
// Type unknown: mark it present so an existing row survives.
|
|
||||||
Err(_) => mark_present(name),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !unreadable_entry {
|
if !unreadable_entry {
|
||||||
stale.extend(
|
stale.extend(rows.into_keys().map(|name| path_to_db_string(&dir.join(name))));
|
||||||
rows.keys()
|
|
||||||
.filter(|name| !present.contains(name.as_str()))
|
|
||||||
.map(|name| path_to_db_string(&dir.join(name))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spread a wide directory across the pool, keeping the tail for ourselves
|
// Spread a wide directory across the pool, keeping the tail for ourselves
|
||||||
// so the entries the read just warmed are handled now.
|
// so the entries the read just warmed are handled now.
|
||||||
while files.len() > FILES_PER_JOB {
|
while files.len() > FILES_PER_JOB {
|
||||||
let chunk = files.split_off(files.len() - FILES_PER_JOB);
|
let chunk = files.split_off(files.len() - FILES_PER_JOB);
|
||||||
found.push(Found::Files(chunk, rows.clone()));
|
found.push(Found::Files(chunk));
|
||||||
}
|
}
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|
@ -333,38 +343,35 @@ fn resolve_link(path: &Path, ctx: &Ctx) -> Option<Found> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a file's stored mtime is to be found.
|
|
||||||
enum Known<'a> {
|
|
||||||
InDir(&'a DirRows),
|
|
||||||
/// Resolved by exact path: a symlink target's row lives under a
|
|
||||||
/// different parent.
|
|
||||||
Exact(Option<u64>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// At most one `stat`, then classify; only files that will be written get
|
/// At most one `stat`, then classify; only files that will be written get
|
||||||
/// opened, and small text files are finished outright. "At most": on Windows
|
/// opened, and small text files are finished outright. "At most": on Windows
|
||||||
/// [`PendingFile::cached`] may already hold the answer.
|
/// [`PendingFile::cached`] may already hold the answer.
|
||||||
///
|
///
|
||||||
/// `scratch` is this worker's, carrying the head buffer every hash and MIME
|
/// `aliased`: reached by resolving a symlink, so its row lives under a
|
||||||
/// sniff reads into.
|
/// different parent than the link's. `scratch` is this worker's, carrying the
|
||||||
|
/// head buffer every hash and MIME sniff reads into.
|
||||||
fn prepare(
|
fn prepare(
|
||||||
file: PendingFile,
|
file: PendingFile,
|
||||||
known: Known<'_>,
|
aliased: bool,
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
scratch: &mut crate::extract::Scratch,
|
scratch: &mut crate::extract::Scratch,
|
||||||
) -> WalkedFile {
|
) -> WalkedFile {
|
||||||
let PendingFile { path, cached } = file;
|
let PendingFile {
|
||||||
// Every route here has already screened the path for UTF-8; see
|
path,
|
||||||
// `path_to_db_string`.
|
cached,
|
||||||
debug_assert!(
|
stored,
|
||||||
path.to_str().is_some(),
|
} = file;
|
||||||
"an unrepresentable path reached prepare(): {:?}",
|
// Every route here has already screened the path for UTF-8, and every
|
||||||
path
|
// walk path is a normalized root or link target with names joined on, so
|
||||||
);
|
// it already is its stored spelling: the buffer becomes the `String`
|
||||||
let db_path = path_to_db_string(&path);
|
// without a copy (see `path_to_db_string`).
|
||||||
let aliased = matches!(known, Known::Exact(_));
|
let db_path = path.into_os_string().into_string().unwrap_or_else(|path| {
|
||||||
|
debug_assert!(false, "an unrepresentable path reached prepare(): {:?}", path);
|
||||||
|
path_to_db_string(Path::new(&path))
|
||||||
|
});
|
||||||
|
debug_assert_eq!(db_path, path_to_db_string(Path::new(&db_path)));
|
||||||
|
|
||||||
let Ok(meta) = crate::platform::metadata_or_stat(&path, cached) else {
|
let Ok(meta) = crate::platform::metadata_or_stat(Path::new(&db_path), cached) else {
|
||||||
// Seen but unreadable: a transient stat failure must not read as
|
// Seen but unreadable: a transient stat failure must not read as
|
||||||
// "deleted".
|
// "deleted".
|
||||||
return WalkedFile::skipped(db_path, aliased);
|
return WalkedFile::skipped(db_path, aliased);
|
||||||
|
|
@ -373,17 +380,7 @@ fn prepare(
|
||||||
return WalkedFile::skipped(db_path, aliased);
|
return WalkedFile::skipped(db_path, aliased);
|
||||||
};
|
};
|
||||||
|
|
||||||
let action = match known {
|
let action = classify_by_mtime(stored, mtime);
|
||||||
Known::InDir(rows) => {
|
|
||||||
// `to_str`, never lossy; the screen makes it always `Some`.
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
classify_for_indexing(name, mtime, rows)
|
|
||||||
}
|
|
||||||
Known::Exact(stored) => classify_by_mtime(stored, mtime),
|
|
||||||
};
|
|
||||||
let record = match action {
|
let record = match action {
|
||||||
// Unchanged: never opened or hashed, so it stays at one syscall.
|
// Unchanged: never opened or hashed, so it stays at one syscall.
|
||||||
FileIndexAction::Skip => None,
|
FileIndexAction::Skip => None,
|
||||||
|
|
@ -414,20 +411,24 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender<WalkEvent>) {
|
||||||
let delivered = match job {
|
let delivered = match job {
|
||||||
Job::Dir(dir, rows) => {
|
Job::Dir(dir, rows) => {
|
||||||
let (mut found, mut stale) = (Vec::new(), Vec::new());
|
let (mut found, mut stale) = (Vec::new(), Vec::new());
|
||||||
let files = read_directory(&dir, &rows, ctx, &mut found, &mut stale);
|
let files = read_directory(&dir, rows, ctx, &mut found, &mut stale);
|
||||||
// Hand the subdirectories over before doing our own per-file
|
// Hand the subdirectories over before doing our own per-file
|
||||||
// work, so the rest of the pool never idles behind one worker.
|
// work, so the rest of the pool never idles behind one worker.
|
||||||
slot.finish(found);
|
slot.finish(found);
|
||||||
deliver(files, &rows, stale, ctx, tx, &mut scratch)
|
deliver(files, stale, ctx, tx, &mut scratch)
|
||||||
}
|
}
|
||||||
Job::Files(files, rows) => {
|
Job::Files(files) => {
|
||||||
slot.finish(Vec::new());
|
slot.finish(Vec::new());
|
||||||
deliver(files, &rows, Vec::new(), ctx, tx, &mut scratch)
|
deliver(files, Vec::new(), ctx, tx, &mut scratch)
|
||||||
}
|
}
|
||||||
Job::Alias(path, stored) => {
|
Job::Alias(path, stored) => {
|
||||||
slot.finish(Vec::new());
|
slot.finish(Vec::new());
|
||||||
let file = PendingFile { path, cached: None };
|
let file = PendingFile {
|
||||||
let walked = prepare(file, Known::Exact(stored), ctx, &mut scratch);
|
path,
|
||||||
|
cached: None,
|
||||||
|
stored,
|
||||||
|
};
|
||||||
|
let walked = prepare(file, true, ctx, &mut scratch);
|
||||||
tx.send(WalkEvent::File(walked)).is_ok()
|
tx.send(WalkEvent::File(walked)).is_ok()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -443,7 +444,6 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender<WalkEvent>) {
|
||||||
/// failed: not an error.
|
/// failed: not an error.
|
||||||
fn deliver(
|
fn deliver(
|
||||||
files: Vec<PendingFile>,
|
files: Vec<PendingFile>,
|
||||||
rows: &DirRows,
|
|
||||||
stale: Vec<String>,
|
stale: Vec<String>,
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
tx: &mpsc::SyncSender<WalkEvent>,
|
tx: &mpsc::SyncSender<WalkEvent>,
|
||||||
|
|
@ -456,7 +456,7 @@ fn deliver(
|
||||||
if ctx.stop_flag.load(Ordering::Relaxed) {
|
if ctx.stop_flag.load(Ordering::Relaxed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let walked = prepare(file, Known::InDir(rows), ctx, scratch);
|
let walked = prepare(file, false, ctx, scratch);
|
||||||
tx.send(WalkEvent::File(walked)).is_ok()
|
tx.send(WalkEvent::File(walked)).is_ok()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -478,7 +478,7 @@ fn prefetcher(shared: &Shared, db_path: &str) {
|
||||||
while let Some(work) = shared.take_prefetch() {
|
while let Some(work) = shared.take_prefetch() {
|
||||||
let job = match work {
|
let job = match work {
|
||||||
PrefetchWork::Dir(dir) => crate::db::repo::dir_rows(&conn, &dir_to_db_parent(&dir))
|
PrefetchWork::Dir(dir) => crate::db::repo::dir_rows(&conn, &dir_to_db_parent(&dir))
|
||||||
.map(|rows| Job::Dir(dir, Arc::new(rows))),
|
.map(|rows| Job::Dir(dir, rows)),
|
||||||
PrefetchWork::Alias(path) => {
|
PrefetchWork::Alias(path) => {
|
||||||
crate::db::repo::mtime_for_path(&conn, &path_to_db_string(&path))
|
crate::db::repo::mtime_for_path(&conn, &path_to_db_string(&path))
|
||||||
.map(|stored| Job::Alias(path, stored))
|
.map(|stored| Job::Alias(path, stored))
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ pub(super) struct Queue {
|
||||||
/// end-of-walk proof in [`Shared::take`].
|
/// end-of-walk proof in [`Shared::take`].
|
||||||
pub(super) prefetching: bool,
|
pub(super) prefetching: bool,
|
||||||
/// What [`PREFETCH_AHEAD`] bounds. Not `jobs.len()`: that also counts
|
/// What [`PREFETCH_AHEAD`] bounds. Not `jobs.len()`: that also counts
|
||||||
/// `Job::Files` chunks, which carry no rows and would starve prefetching.
|
/// `Job::Files` chunks, which hold no directory's rows and would starve
|
||||||
|
/// prefetching.
|
||||||
pub(super) dirs_ready: usize,
|
pub(super) dirs_ready: usize,
|
||||||
pub(super) active: usize,
|
pub(super) active: usize,
|
||||||
/// Canonical directories already queued: collapses overlapping roots,
|
/// Canonical directories already queued: collapses overlapping roots,
|
||||||
|
|
@ -173,8 +174,8 @@ pub(super) enum PrefetchWork {
|
||||||
pub(super) enum Found {
|
pub(super) enum Found {
|
||||||
Dir(PathBuf),
|
Dir(PathBuf),
|
||||||
Alias(PathBuf),
|
Alias(PathBuf),
|
||||||
/// Overflow files from the directory just read, which already has rows.
|
/// Overflow files from the directory just read, each carrying its row.
|
||||||
Files(Vec<PendingFile>, Arc<DirRows>),
|
Files(Vec<PendingFile>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shared {
|
impl Shared {
|
||||||
|
|
@ -192,7 +193,7 @@ impl Shared {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Found::Alias(path) => q.needs_alias.push(path),
|
Found::Alias(path) => q.needs_alias.push(path),
|
||||||
Found::Files(files, rows) => q.jobs.push(Job::Files(files, rows)),
|
Found::Files(files) => q.jobs.push(Job::Files(files)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
q.active -= 1;
|
q.active -= 1;
|
||||||
|
|
|
||||||
|
|
@ -177,9 +177,9 @@ fn a_non_utf8_name_is_dropped_at_the_entry() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `continue` in `read_directory` leaves the name out of `present`, and
|
/// A `continue` in `read_directory` leaves the name's row in `rows`, and
|
||||||
/// everything left out of `present` is deleted; the row spelled with the lossy
|
/// every row left there is deleted; the row spelled with the lossy name
|
||||||
/// name belongs to a different file, which must mark itself present.
|
/// belongs to a different file, which must take its own row out.
|
||||||
#[test]
|
#[test]
|
||||||
fn dropping_a_bad_entry_deletes_nothing() {
|
fn dropping_a_bad_entry_deletes_nothing() {
|
||||||
let root = tmp_tree("nonutf8-stale");
|
let root = tmp_tree("nonutf8-stale");
|
||||||
|
|
@ -659,6 +659,50 @@ fn a_directory_reports_rows_with_no_file_behind_them() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A directory wider than one job is spread across the pool, and each file
|
||||||
|
/// takes its stored mtime with it: every chunk must classify against the
|
||||||
|
/// file's own row, and only the row with no file is stale.
|
||||||
|
#[test]
|
||||||
|
fn every_file_of_a_wide_directory_is_classified_against_its_own_row() {
|
||||||
|
let root = tmp_tree("wide");
|
||||||
|
let total = FILES_PER_JOB * 2 + 7;
|
||||||
|
let path = |i: usize| root.join(format!("f{:04}.txt", i));
|
||||||
|
for i in 0..total {
|
||||||
|
touch(&path(i), b"x");
|
||||||
|
}
|
||||||
|
let mtime =
|
||||||
|
|i: usize| crate::file_handling::mtime_secs(&fs::metadata(path(i)).unwrap()).unwrap();
|
||||||
|
// Thirds: unchanged, changed, never indexed.
|
||||||
|
let mut rows: Vec<(String, u64)> = (0..total)
|
||||||
|
.filter(|i| i % 3 != 2)
|
||||||
|
.map(|i| (path_to_db_string(&path(i)), mtime(i) + (i % 3) as u64))
|
||||||
|
.collect();
|
||||||
|
let gone = path_to_db_string(&root.join("gone.txt"));
|
||||||
|
rows.push((gone.clone(), 1));
|
||||||
|
let db = db_with("wide", &rows);
|
||||||
|
|
||||||
|
let (mut files, mut stale) = (Vec::new(), Vec::new());
|
||||||
|
for event in start(&root, &db) {
|
||||||
|
match event {
|
||||||
|
WalkEvent::File(f) => files.push(f),
|
||||||
|
WalkEvent::Stale(paths) => stale.extend(paths),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(files.len(), total);
|
||||||
|
for f in &files {
|
||||||
|
let name = Path::new(&f.path).file_name().unwrap().to_str().unwrap();
|
||||||
|
let i: usize = name[1..5].parse().unwrap();
|
||||||
|
let want = [
|
||||||
|
FileIndexAction::Skip,
|
||||||
|
FileIndexAction::Update,
|
||||||
|
FileIndexAction::Insert,
|
||||||
|
][i % 3];
|
||||||
|
assert_eq!(f.action, want, "{}", name);
|
||||||
|
}
|
||||||
|
assert_eq!(stale, vec![gone], "only the row with no file behind it");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn an_unreadable_directory_reports_nothing_stale() {
|
fn an_unreadable_directory_reports_nothing_stale() {
|
||||||
|
|
@ -819,7 +863,7 @@ fn the_index_and_its_sidecars_are_never_walked() {
|
||||||
|
|
||||||
/// A stored row for the index itself has to go, or the live watcher and
|
/// A stored row for the index itself has to go, or the live watcher and
|
||||||
/// duplicate verification keep opening the file; a skip would keep the row,
|
/// duplicate verification keep opening the file; a skip would keep the row,
|
||||||
/// so the walk leaves the name out of the directory's `present` set instead.
|
/// so the walk leaves the name's row in the directory's `rows` instead.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_row_for_the_index_falls_to_the_stale_sweep() {
|
fn a_stored_row_for_the_index_falls_to_the_stale_sweep() {
|
||||||
let root = crate::testutil::scratch_dir_canonical("walk-self-index-stale");
|
let root = crate::testutil::scratch_dir_canonical("walk-self-index-stale");
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
//! the whole watcher fails: a half-watched root looks live while going
|
//! the whole watcher fails: a half-watched root looks live while going
|
||||||
//! silently stale.
|
//! silently stale.
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use notify::event::{ModifyKind, RenameMode};
|
use notify::event::{ModifyKind, RenameMode};
|
||||||
use notify::{
|
use notify::{
|
||||||
|
|
@ -116,20 +116,15 @@ fn fmt_cap(cap: usize) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tuning for the whole filesystem-event pipeline.
|
/// Tuning for the whole filesystem-event pipeline. The watcher forwards
|
||||||
|
/// events as they arrive; the coordinator's queue coalesces them by path and
|
||||||
|
/// decides when to apply them.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WatcherConfig {
|
pub struct WatcherConfig {
|
||||||
/// How long the coordinator's queue must go quiet before it is applied.
|
/// How long the coordinator's queue must go quiet before it is applied.
|
||||||
pub pending_settle: Duration,
|
pub pending_settle: Duration,
|
||||||
/// Ceiling on how long `pending_settle` may hold the queue back.
|
/// Ceiling on how long `pending_settle` may hold the queue back.
|
||||||
pub pending_max_defer: Duration,
|
pub pending_max_defer: Duration,
|
||||||
/// Per-directory debounce window.
|
|
||||||
pub throttle_window: Duration,
|
|
||||||
pub tick_interval: Duration,
|
|
||||||
/// Directories *selected* per tick; [`FLUSH_BUDGET`] is the real limit.
|
|
||||||
pub max_dirs_per_tick: usize,
|
|
||||||
/// When to garbage-collect stale throttle entries (idle > window * N).
|
|
||||||
pub prune_max_age_multiplier: u32,
|
|
||||||
/// Directory budget; see [`DEFAULT_MAX_WATCHED_DIRS`].
|
/// Directory budget; see [`DEFAULT_MAX_WATCHED_DIRS`].
|
||||||
pub max_watched_dirs: usize,
|
pub max_watched_dirs: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -139,10 +134,6 @@ impl Default for WatcherConfig {
|
||||||
Self {
|
Self {
|
||||||
pending_settle: Duration::from_secs(2),
|
pending_settle: Duration::from_secs(2),
|
||||||
pending_max_defer: Duration::from_secs(30),
|
pending_max_defer: Duration::from_secs(30),
|
||||||
throttle_window: Duration::from_secs(30),
|
|
||||||
tick_interval: Duration::from_millis(500),
|
|
||||||
max_dirs_per_tick: 512,
|
|
||||||
prune_max_age_multiplier: 10,
|
|
||||||
max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS,
|
max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,8 +147,6 @@ struct WatchRegistry {
|
||||||
cap: usize,
|
cap: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long one flush pass may spend handing events to the sink.
|
|
||||||
const FLUSH_BUDGET: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY {
|
const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY {
|
||||||
RecursiveMode::Recursive
|
RecursiveMode::Recursive
|
||||||
|
|
@ -240,7 +229,9 @@ fn add_watch(reg: &mut WatchRegistry, dir: &Path) -> Result<(), WatchError> {
|
||||||
|
|
||||||
/// Handle to a running watcher. Dropping calls [`Self::stop`] implicitly.
|
/// Handle to a running watcher. Dropping calls [`Self::stop`] implicitly.
|
||||||
pub struct Watcher {
|
pub struct Watcher {
|
||||||
stop_flag: Arc<AtomicBool>,
|
/// The loop's own channel: `None` ends it. The notify handle holds the
|
||||||
|
/// other sender, so the channel never closes while the loop runs.
|
||||||
|
stop_tx: mpsc::Sender<Option<NotifyEvent>>,
|
||||||
handle: Option<JoinHandle<()>>,
|
handle: Option<JoinHandle<()>>,
|
||||||
dir_count: Arc<AtomicUsize>,
|
dir_count: Arc<AtomicUsize>,
|
||||||
degraded: Degraded,
|
degraded: Degraded,
|
||||||
|
|
@ -261,7 +252,8 @@ impl Watcher {
|
||||||
config: WatcherConfig,
|
config: WatcherConfig,
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
) -> Result<Self, WatchError> {
|
) -> Result<Self, WatchError> {
|
||||||
let (tx, rx) = mpsc::channel::<NotifyEvent>();
|
let (tx, rx) = mpsc::channel::<Option<NotifyEvent>>();
|
||||||
|
let stop_tx = tx.clone();
|
||||||
let degraded: Degraded = Arc::new(Mutex::new(None));
|
let degraded: Degraded = Arc::new(Mutex::new(None));
|
||||||
let degraded_cb = degraded.clone();
|
let degraded_cb = degraded.clone();
|
||||||
let raw = RecommendedWatcher::new(
|
let raw = RecommendedWatcher::new(
|
||||||
|
|
@ -274,7 +266,7 @@ impl Watcher {
|
||||||
crate::lock_ok(°raded_cb).get_or_insert(WatchError::Overflowed);
|
crate::lock_ok(°raded_cb).get_or_insert(WatchError::Overflowed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = tx.send(ev);
|
let _ = tx.send(Some(ev));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// The limit can also be hit asynchronously, when notify
|
// The limit can also be hit asynchronously, when notify
|
||||||
|
|
@ -330,11 +322,8 @@ impl Watcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir_count = Arc::new(AtomicUsize::new(crate::lock_ok(®istry).dirs.len()));
|
let dir_count = Arc::new(AtomicUsize::new(crate::lock_ok(®istry).dirs.len()));
|
||||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
|
||||||
let ctx = LoopCtx {
|
let ctx = LoopCtx {
|
||||||
sink,
|
sink,
|
||||||
config,
|
|
||||||
stop: stop_flag.clone(),
|
|
||||||
registry: registry.clone(),
|
registry: registry.clone(),
|
||||||
filters,
|
filters,
|
||||||
roots,
|
roots,
|
||||||
|
|
@ -344,7 +333,7 @@ impl Watcher {
|
||||||
let handle = thread::spawn(move || run_loop(rx, ctx));
|
let handle = thread::spawn(move || run_loop(rx, ctx));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
stop_flag,
|
stop_tx,
|
||||||
handle: Some(handle),
|
handle: Some(handle),
|
||||||
dir_count,
|
dir_count,
|
||||||
degraded,
|
degraded,
|
||||||
|
|
@ -376,7 +365,7 @@ impl Watcher {
|
||||||
|
|
||||||
/// Stop the background thread and join it; safe to call multiple times.
|
/// Stop the background thread and join it; safe to call multiple times.
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
self.stop_flag.store(true, Ordering::Relaxed);
|
let _ = self.stop_tx.send(None);
|
||||||
if let Some(h) = self.handle.take() {
|
if let Some(h) = self.handle.take() {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
|
|
@ -400,8 +389,6 @@ impl std::fmt::Debug for Watcher {
|
||||||
|
|
||||||
struct LoopCtx {
|
struct LoopCtx {
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
config: WatcherConfig,
|
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
registry: Arc<Mutex<WatchRegistry>>,
|
registry: Arc<Mutex<WatchRegistry>>,
|
||||||
filters: WatchFilters,
|
filters: WatchFilters,
|
||||||
/// Configured roots; "hidden" is judged relative to them, since a root
|
/// Configured roots; "hidden" is judged relative to them, since a root
|
||||||
|
|
@ -412,56 +399,21 @@ struct LoopCtx {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum QueuedOp {
|
enum Op {
|
||||||
Create,
|
Create,
|
||||||
Modify,
|
Modify,
|
||||||
Remove,
|
Remove,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
/// Forward each interesting event to the sink as it arrives, blocking in
|
||||||
struct DirThrottleEntry {
|
/// between; `None` is [`Watcher::stop`]. Nothing is held back here: the
|
||||||
record_time: Instant,
|
/// coordinator's queue coalesces by path and waits for `pending_settle`
|
||||||
queue: HashMap<PathBuf, QueuedOp>,
|
/// (at most `pending_max_defer`). A per-directory throttle here used to hold
|
||||||
/// Flush on the next tick regardless of window age; set for the first
|
/// a directory's second event for 30 s and woke this thread twice a second
|
||||||
/// event in a previously-idle directory so it reacts fast.
|
/// while idle.
|
||||||
immediate: bool,
|
fn run_loop(rx: mpsc::Receiver<Option<NotifyEvent>>, ctx: LoopCtx) {
|
||||||
}
|
while let Ok(Some(ev)) = rx.recv() {
|
||||||
|
handle_notify_event(&ev, &ctx);
|
||||||
/// Ticks between sweeps of idle throttle entries.
|
|
||||||
const PRUNE_EVERY_TICKS: u32 = 20;
|
|
||||||
|
|
||||||
fn run_loop(rx: mpsc::Receiver<NotifyEvent>, ctx: LoopCtx) {
|
|
||||||
let mut throttle: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
|
||||||
let mut tick_counter: u32 = 0;
|
|
||||||
|
|
||||||
while !ctx.stop.load(Ordering::Relaxed) {
|
|
||||||
let deadline = Instant::now() + ctx.config.tick_interval;
|
|
||||||
while !ctx.stop.load(Ordering::Relaxed) {
|
|
||||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
||||||
if remaining.is_zero() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match rx.recv_timeout(remaining) {
|
|
||||||
Ok(ev) => handle_notify_event(&ev, &mut throttle, &ctx),
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.stop.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
flush_ready(&mut throttle, &ctx.sink, &ctx.config);
|
|
||||||
|
|
||||||
tick_counter = tick_counter.wrapping_add(1);
|
|
||||||
if tick_counter.is_multiple_of(PRUNE_EVERY_TICKS) {
|
|
||||||
let max_age = ctx
|
|
||||||
.config
|
|
||||||
.throttle_window
|
|
||||||
.saturating_mul(ctx.config.prune_max_age_multiplier);
|
|
||||||
prune_stale(&mut throttle, max_age);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,7 +477,7 @@ fn unwatch_tree(ctx: &LoopCtx, path: &Path) {
|
||||||
|
|
||||||
/// Whether an event for `path` is worth queueing at all: the same predicate
|
/// Whether an event for `path` is worth queueing at all: the same predicate
|
||||||
/// the walk applies. Under a recursive root watch it alone keeps
|
/// the walk applies. Under a recursive root watch it alone keeps
|
||||||
/// `node_modules` churn out of the throttle map. A path the index cannot
|
/// `node_modules` churn out of the coordinator's queue. A path the index cannot
|
||||||
/// spell is dropped (see `path_to_db_string`).
|
/// spell is dropped (see `path_to_db_string`).
|
||||||
fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
|
fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
|
||||||
path.to_str().is_some()
|
path.to_str().is_some()
|
||||||
|
|
@ -533,11 +485,7 @@ fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
|
||||||
&& (ctx.filters.include_hidden || !path_has_hidden_component_under(path, &ctx.roots))
|
&& (ctx.filters.include_hidden || !path_has_hidden_component_under(path, &ctx.roots))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_notify_event(
|
fn handle_notify_event(ev: &NotifyEvent, ctx: &LoopCtx) {
|
||||||
ev: &NotifyEvent,
|
|
||||||
throttle: &mut HashMap<PathBuf, DirThrottleEntry>,
|
|
||||||
ctx: &LoopCtx,
|
|
||||||
) {
|
|
||||||
let op = match ev.kind {
|
let op = match ev.kind {
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if ev.paths.len() == 2 => {
|
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if ev.paths.len() == 2 => {
|
||||||
// A rename is only uninteresting when *both* ends are: out of an
|
// A rename is only uninteresting when *both* ends are: out of an
|
||||||
|
|
@ -556,13 +504,9 @@ fn handle_notify_event(
|
||||||
}
|
}
|
||||||
// Split renames degrade to Create/Remove; notify 6.x does not bundle
|
// Split renames degrade to Create/Remove; notify 6.x does not bundle
|
||||||
// halves by cookie uniformly across backends.
|
// halves by cookie uniformly across backends.
|
||||||
EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
|
EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(RenameMode::To)) => Op::Create,
|
||||||
QueuedOp::Create
|
EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => Op::Remove,
|
||||||
}
|
EventKind::Modify(_) => Op::Modify,
|
||||||
EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
|
|
||||||
QueuedOp::Remove
|
|
||||||
}
|
|
||||||
EventKind::Modify(_) => QueuedOp::Modify,
|
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -570,83 +514,20 @@ fn handle_notify_event(
|
||||||
if !is_event_interesting(ctx, p) {
|
if !is_event_interesting(ctx, p) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Keep the watch set in step before debouncing: a directory created
|
// Keep the watch set in step before the event is applied: a
|
||||||
// now may be populated before its window expires and needs its watch.
|
// directory created now may be populated at once and needs its watch.
|
||||||
match op {
|
let event = match op {
|
||||||
QueuedOp::Create => watch_if_new_dir(ctx, p),
|
Op::Create => {
|
||||||
QueuedOp::Remove => unwatch_tree(ctx, p),
|
watch_if_new_dir(ctx, p);
|
||||||
QueuedOp::Modify => {}
|
FsEvent::Create(p.clone())
|
||||||
}
|
|
||||||
enqueue(throttle, p.clone(), op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enqueue(throttle: &mut HashMap<PathBuf, DirThrottleEntry>, path: PathBuf, op: QueuedOp) {
|
|
||||||
let dir = path
|
|
||||||
.parent()
|
|
||||||
.map(|p| p.to_path_buf())
|
|
||||||
.unwrap_or_else(|| path.clone());
|
|
||||||
let entry = throttle.entry(dir).or_insert_with(|| DirThrottleEntry {
|
|
||||||
record_time: Instant::now(),
|
|
||||||
queue: HashMap::new(),
|
|
||||||
immediate: true,
|
|
||||||
});
|
|
||||||
// Created and deleted within one window: the file never surfaces.
|
|
||||||
if op == QueuedOp::Remove && entry.queue.get(&path) == Some(&QueuedOp::Create) {
|
|
||||||
entry.queue.remove(&path);
|
|
||||||
} else {
|
|
||||||
entry.queue.insert(path, op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_ready(
|
|
||||||
throttle: &mut HashMap<PathBuf, DirThrottleEntry>,
|
|
||||||
sink: &EventSink,
|
|
||||||
config: &WatcherConfig,
|
|
||||||
) {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut ready: Vec<PathBuf> = Vec::new();
|
|
||||||
for (dir, entry) in throttle.iter() {
|
|
||||||
let age = now.saturating_duration_since(entry.record_time);
|
|
||||||
if entry.immediate || (!entry.queue.is_empty() && age >= config.throttle_window) {
|
|
||||||
ready.push(dir.clone());
|
|
||||||
if ready.len() >= config.max_dirs_per_tick {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
Op::Remove => {
|
||||||
}
|
unwatch_tree(ctx, p);
|
||||||
let deadline = now + FLUSH_BUDGET;
|
FsEvent::Remove(p.clone())
|
||||||
for dir in ready {
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(entry) = throttle.get_mut(&dir) {
|
|
||||||
let drained: Vec<(PathBuf, QueuedOp)> = entry.queue.drain().collect();
|
|
||||||
entry.immediate = false;
|
|
||||||
entry.record_time = now;
|
|
||||||
for (path, op) in drained {
|
|
||||||
let ev = match op {
|
|
||||||
QueuedOp::Create => FsEvent::Create(path),
|
|
||||||
QueuedOp::Modify => FsEvent::Modify(path),
|
|
||||||
QueuedOp::Remove => FsEvent::Remove(path),
|
|
||||||
};
|
|
||||||
sink(ev);
|
|
||||||
}
|
}
|
||||||
}
|
Op::Modify => FsEvent::Modify(p.clone()),
|
||||||
}
|
};
|
||||||
}
|
(ctx.sink)(event);
|
||||||
|
|
||||||
fn prune_stale(throttle: &mut HashMap<PathBuf, DirThrottleEntry>, max_age: Duration) {
|
|
||||||
let now = Instant::now();
|
|
||||||
throttle.retain(|_, entry| {
|
|
||||||
!entry.queue.is_empty()
|
|
||||||
|| entry.immediate
|
|
||||||
|| now.saturating_duration_since(entry.record_time) < max_age
|
|
||||||
});
|
|
||||||
// `retain` keeps the table sized for the busiest moment this map has ever
|
|
||||||
// seen; a single unpack burst would otherwise hold that memory for days.
|
|
||||||
if throttle.is_empty() {
|
|
||||||
throttle.shrink_to_fit();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,11 @@ fn default_filters() -> WatchFilters {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fast_config() -> WatcherConfig {
|
/// The default config with a directory budget of `n`.
|
||||||
WatcherConfig {
|
|
||||||
throttle_window: Duration::from_millis(50),
|
|
||||||
tick_interval: Duration::from_millis(20),
|
|
||||||
..WatcherConfig::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`fast_config`] with a directory budget of `n`.
|
|
||||||
fn capped(n: usize) -> WatcherConfig {
|
fn capped(n: usize) -> WatcherConfig {
|
||||||
WatcherConfig {
|
WatcherConfig {
|
||||||
max_watched_dirs: n,
|
max_watched_dirs: n,
|
||||||
..fast_config()
|
..WatcherConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,9 +31,9 @@ fn watch_with(dir: &Path, config: WatcherConfig, sink: EventSink) -> Result<Watc
|
||||||
Watcher::start(vec![dir.to_path_buf()], default_filters(), config, sink)
|
Watcher::start(vec![dir.to_path_buf()], default_filters(), config, sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`watch_with`] at the fast defaults, into a sink nobody reads.
|
/// [`watch_with`] at the defaults, into a sink nobody reads.
|
||||||
fn watch(dir: &Path) -> Watcher {
|
fn watch(dir: &Path) -> Watcher {
|
||||||
watch_with(dir, fast_config(), sink_to_vec().0).unwrap()
|
watch_with(dir, WatcherConfig::default(), sink_to_vec().0).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seeds `dirs` directly so set logic is tested without real kernel watches.
|
/// Seeds `dirs` directly so set logic is tested without real kernel watches.
|
||||||
|
|
@ -72,88 +64,44 @@ fn remove_tree_skips_the_scan_for_a_path_that_is_not_watched() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nothing holds an event back: a directory's second change reaches the
|
||||||
|
/// sink as promptly as its first. A per-directory throttle once held it for
|
||||||
|
/// 30 s; the coordinator's queue does the debouncing now.
|
||||||
#[test]
|
#[test]
|
||||||
fn enqueue_create_then_remove_cancels() {
|
fn every_event_reaches_the_sink_as_it_happens() {
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
let dir = crate::testutil::scratch_dir("prompt");
|
||||||
let p = PathBuf::from("/tmp/a.txt");
|
|
||||||
enqueue(&mut map, p.clone(), QueuedOp::Create);
|
|
||||||
enqueue(&mut map, p.clone(), QueuedOp::Remove);
|
|
||||||
let entry = map.get(p.parent().unwrap()).unwrap();
|
|
||||||
assert!(entry.queue.is_empty(), "Create then Remove should cancel");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn modify_after_modify_is_one() {
|
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
|
||||||
let p = PathBuf::from("/tmp/a.txt");
|
|
||||||
enqueue(&mut map, p.clone(), QueuedOp::Modify);
|
|
||||||
enqueue(&mut map, p.clone(), QueuedOp::Modify);
|
|
||||||
let entry = map.get(p.parent().unwrap()).unwrap();
|
|
||||||
assert_eq!(entry.queue.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flush_ready_leading_edge_fires_immediately() {
|
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
|
||||||
enqueue(&mut map, PathBuf::from("/tmp/a.txt"), QueuedOp::Create);
|
|
||||||
let (sink, got) = sink_to_vec();
|
let (sink, got) = sink_to_vec();
|
||||||
let config = WatcherConfig::default();
|
let mut w = watch_with(&dir, WatcherConfig::default(), sink).unwrap();
|
||||||
flush_ready(&mut map, &sink, &config);
|
|
||||||
let got = got.lock().unwrap();
|
|
||||||
assert_eq!(got.len(), 1);
|
|
||||||
assert!(matches!(got[0], FsEvent::Create(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
let seen = |name: &str| {
|
||||||
fn flush_ready_respects_max_dirs_per_tick() {
|
let path = dir.join(name);
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
for i in 0..10 {
|
while std::time::Instant::now() < deadline {
|
||||||
enqueue(
|
let found = got
|
||||||
&mut map,
|
.lock()
|
||||||
PathBuf::from(format!("/dir{}/a", i)),
|
.unwrap()
|
||||||
QueuedOp::Create,
|
.iter()
|
||||||
);
|
.any(|e| matches!(e, FsEvent::Create(p) | FsEvent::Modify(p) if p == &path));
|
||||||
}
|
if found {
|
||||||
let (sink, got) = sink_to_vec();
|
return true;
|
||||||
let config = WatcherConfig {
|
}
|
||||||
max_dirs_per_tick: 3,
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
..WatcherConfig::default()
|
}
|
||||||
|
false
|
||||||
};
|
};
|
||||||
flush_ready(&mut map, &sink, &config);
|
std::fs::write(dir.join("first.txt"), "a").unwrap();
|
||||||
// Each dir contributes one event because each entry has one path.
|
assert!(seen("first.txt"));
|
||||||
assert_eq!(got.lock().unwrap().len(), 3);
|
let second = std::time::Instant::now();
|
||||||
}
|
std::fs::write(dir.join("second.txt"), "b").unwrap();
|
||||||
|
assert!(seen("second.txt"));
|
||||||
#[test]
|
assert!(
|
||||||
fn prune_stale_drops_empty_old_entries() {
|
second.elapsed() < Duration::from_secs(2),
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
"the second event in a directory took {:?}",
|
||||||
map.insert(
|
second.elapsed()
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
DirThrottleEntry {
|
|
||||||
record_time: Instant::now() - Duration::from_secs(3600),
|
|
||||||
queue: HashMap::new(),
|
|
||||||
immediate: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
prune_stale(&mut map, Duration::from_secs(1));
|
|
||||||
assert!(map.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
w.stop();
|
||||||
fn prune_stale_keeps_active_entries() {
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
let mut map: HashMap<PathBuf, DirThrottleEntry> = HashMap::new();
|
|
||||||
let mut queue = HashMap::new();
|
|
||||||
queue.insert(PathBuf::from("/tmp/a"), QueuedOp::Modify);
|
|
||||||
map.insert(
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
DirThrottleEntry {
|
|
||||||
record_time: Instant::now() - Duration::from_secs(3600),
|
|
||||||
queue,
|
|
||||||
immediate: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
prune_stale(&mut map, Duration::from_secs(1));
|
|
||||||
assert_eq!(map.len(), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -161,7 +109,7 @@ fn e2e_create_modify_remove_surfaces() {
|
||||||
let dir = crate::testutil::scratch_dir("e2e");
|
let dir = crate::testutil::scratch_dir("e2e");
|
||||||
let (sink, got) = sink_to_vec();
|
let (sink, got) = sink_to_vec();
|
||||||
|
|
||||||
let mut w = watch_with(&dir, fast_config(), sink).unwrap();
|
let mut w = watch_with(&dir, WatcherConfig::default(), sink).unwrap();
|
||||||
|
|
||||||
let f = dir.join("hello.txt");
|
let f = dir.join("hello.txt");
|
||||||
std::fs::write(&f, "hi").unwrap();
|
std::fs::write(&f, "hi").unwrap();
|
||||||
|
|
@ -200,7 +148,7 @@ fn events_for_an_unrepresentable_name_never_surface() {
|
||||||
std::fs::remove_file(&bad).unwrap();
|
std::fs::remove_file(&bad).unwrap();
|
||||||
|
|
||||||
let (sink, got) = sink_to_vec();
|
let (sink, got) = sink_to_vec();
|
||||||
let mut w = watch_with(&dir, fast_config(), sink).unwrap();
|
let mut w = watch_with(&dir, WatcherConfig::default(), sink).unwrap();
|
||||||
|
|
||||||
std::fs::write(&bad, "hi").unwrap();
|
std::fs::write(&bad, "hi").unwrap();
|
||||||
std::thread::sleep(Duration::from_millis(150));
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
|
@ -270,7 +218,13 @@ fn include_hidden_registers_dotted_dirs() {
|
||||||
include_hidden: true,
|
include_hidden: true,
|
||||||
..default_filters()
|
..default_filters()
|
||||||
};
|
};
|
||||||
let w = Watcher::start(vec![dir.clone()], filters, fast_config(), sink_to_vec().0).unwrap();
|
let w = Watcher::start(
|
||||||
|
vec![dir.clone()],
|
||||||
|
filters,
|
||||||
|
WatcherConfig::default(),
|
||||||
|
sink_to_vec().0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(w.watched_dirs(), 2, "root + .hidden");
|
assert_eq!(w.watched_dirs(), 2, "root + .hidden");
|
||||||
drop(w);
|
drop(w);
|
||||||
|
|
@ -303,7 +257,7 @@ fn an_unreadable_directory_is_skipped_not_fatal() {
|
||||||
std::fs::create_dir_all(&locked).unwrap();
|
std::fs::create_dir_all(&locked).unwrap();
|
||||||
crate::platform::deny_read(&locked).unwrap();
|
crate::platform::deny_read(&locked).unwrap();
|
||||||
|
|
||||||
let started = watch_with(&dir, fast_config(), sink_to_vec().0);
|
let started = watch_with(&dir, WatcherConfig::default(), sink_to_vec().0);
|
||||||
crate::platform::restore_read(&locked).ok();
|
crate::platform::restore_read(&locked).ok();
|
||||||
let w = started.expect("an unreadable directory must not fail the watcher");
|
let w = started.expect("an unreadable directory must not fail the watcher");
|
||||||
|
|
||||||
|
|
@ -351,7 +305,7 @@ fn a_tree_inside_the_cap_registers() {
|
||||||
fn a_directory_created_after_start_is_watched() {
|
fn a_directory_created_after_start_is_watched() {
|
||||||
let dir = crate::testutil::scratch_dir("newdir");
|
let dir = crate::testutil::scratch_dir("newdir");
|
||||||
let (sink, got) = sink_to_vec();
|
let (sink, got) = sink_to_vec();
|
||||||
let mut w = watch_with(&dir, fast_config(), sink).unwrap();
|
let mut w = watch_with(&dir, WatcherConfig::default(), sink).unwrap();
|
||||||
assert_eq!(w.watched_dirs(), 1, "only the root to begin with");
|
assert_eq!(w.watched_dirs(), 1, "only the root to begin with");
|
||||||
|
|
||||||
let sub = dir.join("later");
|
let sub = dir.join("later");
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,13 @@ pub use quicksearch_core::testutil::{touch, Lcg, BODY_TERM};
|
||||||
|
|
||||||
const INDEX_TIMEOUT: Duration = Duration::from_secs(120);
|
const INDEX_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// One full indexing run, awaited via the `last_full_index` marker, which is
|
/// One full indexing run, awaited until `Idle`, then checked for the
|
||||||
/// written only on a successful finish. Polling for `Idle` would race: a small
|
/// `last_full_index` marker, which is written only on a successful finish.
|
||||||
/// tree finishes between two polls, leaving `Idle` ambiguous between "not
|
///
|
||||||
/// started yet" and "already done".
|
/// `Idle` and not the marker ends the wait: the marker lands before the
|
||||||
|
/// run's tail and the post-run VACUUM, which would otherwise still be
|
||||||
|
/// writing when the caller's next statement runs. `Idle` is unambiguous
|
||||||
|
/// because `start_indexing` claims the status before it returns.
|
||||||
pub struct IndexOnce<'a> {
|
pub struct IndexOnce<'a> {
|
||||||
pub db: &'a Path,
|
pub db: &'a Path,
|
||||||
pub roots: Vec<String>,
|
pub roots: Vec<String>,
|
||||||
|
|
@ -25,8 +28,8 @@ pub struct IndexOnce<'a> {
|
||||||
/// Delete any existing completion marker first. Off for suites indexing a
|
/// Delete any existing completion marker first. Off for suites indexing a
|
||||||
/// database whose lifecycle they are themselves testing.
|
/// database whose lifecycle they are themselves testing.
|
||||||
pub fresh_marker: bool,
|
pub fresh_marker: bool,
|
||||||
/// Poll the marker through the keyed open: a plain open cannot read an
|
/// Read the marker through the keyed open: a plain open cannot read an
|
||||||
/// encrypted index and would never observe its own completion.
|
/// encrypted index.
|
||||||
pub encrypted: bool,
|
pub encrypted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,28 +51,29 @@ impl IndexOnce<'_> {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let deadline = Instant::now() + INDEX_TIMEOUT;
|
let deadline = Instant::now() + INDEX_TIMEOUT;
|
||||||
let mut done = false;
|
loop {
|
||||||
while Instant::now() < deadline {
|
match service.get_status() {
|
||||||
if let IndexingStatus::Error(e) = service.get_status() {
|
IndexingStatus::Idle => break,
|
||||||
panic!("indexing failed: {}", e);
|
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
|
||||||
}
|
_ => {}
|
||||||
if self.db.exists() && self.completed() {
|
|
||||||
done = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"indexing did not finish within {:?}",
|
||||||
|
INDEX_TIMEOUT
|
||||||
|
);
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
assert!(done, "indexing did not finish within {:?}", INDEX_TIMEOUT);
|
assert!(self.completed(), "the run ended without finishing");
|
||||||
service.stop_indexing().unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the marker is present; mid-creation is simply "not yet".
|
/// Whether the marker is present.
|
||||||
fn completed(&self) -> bool {
|
fn completed(&self) -> bool {
|
||||||
let conn = if self.encrypted {
|
let conn = if self.encrypted {
|
||||||
db::open_existing(&self.db.to_string_lossy(), false).ok()
|
db::open_existing(&self.db.to_string_lossy(), false).unwrap()
|
||||||
} else {
|
} else {
|
||||||
rusqlite::Connection::open(self.db).ok()
|
rusqlite::Connection::open(self.db).unwrap()
|
||||||
};
|
};
|
||||||
conn.is_some_and(|c| db::repo::get_last_full_index(&c).is_some())
|
db::repo::get_last_full_index(&conn).is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,11 +146,6 @@ impl QuickSearchApp {
|
||||||
{
|
{
|
||||||
ctx.request_repaint_after(Duration::from_millis(250));
|
ctx.request_repaint_after(Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
// Watcher registration walks every root, so its verdict can land
|
|
||||||
// minutes after startup, with no input to repaint on.
|
|
||||||
if matches!(state.watcher, WatcherStatus::Starting) {
|
|
||||||
ctx.request_repaint_after(Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,16 @@ struct Registry {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Backend {
|
enum Backend {
|
||||||
/// Nothing registered: no shortcut set, a Wayland session (see the
|
/// A Wayland session (see the module docs): nothing is ever registered.
|
||||||
/// module docs), or the backend never started.
|
|
||||||
Idle,
|
Idle,
|
||||||
/// Windows and X11.
|
/// Windows and X11, holding no key, and so no manager either: on X11 one
|
||||||
|
/// polls the display twenty times a second for as long as it exists,
|
||||||
|
/// whether or not it holds anything.
|
||||||
|
Ready,
|
||||||
|
/// Windows and X11, holding `registered`.
|
||||||
Grab {
|
Grab {
|
||||||
manager: GlobalHotKeyManager,
|
manager: GlobalHotKeyManager,
|
||||||
registered: Option<HotKey>,
|
registered: HotKey,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,34 +78,27 @@ pub fn init(ctx: &egui::Context, setting: &str) {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let (registry, started) = match choose_backend() {
|
// No X11 grab as a fallback under Wayland: it succeeds inside a Wayland
|
||||||
Ok(backend) => (
|
// session and then only fires while an XWayland window has focus, which
|
||||||
Registry {
|
// looks broken.
|
||||||
backend,
|
let backend = if crate::activate::raise::is_wayland() {
|
||||||
status: Status::Disabled,
|
Backend::Idle
|
||||||
},
|
} else {
|
||||||
true,
|
Backend::Ready
|
||||||
),
|
|
||||||
Err(message) => {
|
|
||||||
quicksearch_core::log_warn!("global shortcut: {}", message);
|
|
||||||
(
|
|
||||||
Registry {
|
|
||||||
backend: Backend::Idle,
|
|
||||||
status: Status::Error(message),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
REGISTRY.with_borrow_mut(|slot| *slot = Some(registry));
|
REGISTRY.with_borrow_mut(|slot| {
|
||||||
if started {
|
*slot = Some(Registry {
|
||||||
apply(setting);
|
backend,
|
||||||
}
|
status: Status::Disabled,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
apply(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers `setting`, releasing whatever was registered before. An
|
/// Registers `setting`, releasing whatever was registered before. An
|
||||||
/// unparseable or refused shortcut is reported through [`status`], never by
|
/// unparseable or refused shortcut is reported through [`status`], never by
|
||||||
/// failing: not worth blocking a config the user has already applied.
|
/// failing: not worth blocking a config the user has already applied. On the
|
||||||
|
/// event-loop thread, like [`init`]: registering creates the manager.
|
||||||
pub fn apply(setting: &str) {
|
pub fn apply(setting: &str) {
|
||||||
REGISTRY.with_borrow_mut(|slot| {
|
REGISTRY.with_borrow_mut(|slot| {
|
||||||
let Some(registry) = slot.as_mut() else {
|
let Some(registry) = slot.as_mut() else {
|
||||||
|
|
@ -156,53 +152,43 @@ pub fn status() -> Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend {
|
impl Backend {
|
||||||
/// Hold `wanted` and nothing else. `None` releases without registering.
|
/// Hold `wanted` and nothing else. `None` releases without registering,
|
||||||
|
/// and drops the manager with it.
|
||||||
fn register(&mut self, wanted: Option<Binding>) -> Result<(), String> {
|
fn register(&mut self, wanted: Option<Binding>) -> Result<(), String> {
|
||||||
match self {
|
if matches!(self, Backend::Idle) {
|
||||||
Backend::Idle => Ok(()),
|
return Ok(());
|
||||||
Backend::Grab {
|
}
|
||||||
manager,
|
if let Backend::Grab {
|
||||||
registered,
|
manager,
|
||||||
} => {
|
registered,
|
||||||
if let Some(old) = registered.take() {
|
} = std::mem::replace(self, Backend::Ready)
|
||||||
// Worth reporting, not worth refusing the new binding over.
|
{
|
||||||
if let Err(e) = manager.unregister(old) {
|
// Worth reporting, not worth refusing the new binding over.
|
||||||
quicksearch_core::log_warn!("releasing the old global shortcut: {}", e);
|
if let Err(e) = manager.unregister(registered) {
|
||||||
}
|
quicksearch_core::log_warn!("releasing the old global shortcut: {}", e);
|
||||||
}
|
|
||||||
let Some(binding) = wanted else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let hotkey: HotKey = binding
|
|
||||||
.to_string()
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("{} is not a usable shortcut: {}", binding, e))?;
|
|
||||||
manager.register(hotkey).map_err(|e| match e {
|
|
||||||
global_hotkey::Error::AlreadyRegistered(_) => {
|
|
||||||
format!("another application is already using {}", binding)
|
|
||||||
}
|
|
||||||
other => format!("{} could not be registered: {}", binding, other),
|
|
||||||
})?;
|
|
||||||
*registered = Some(hotkey);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let Some(binding) = wanted else {
|
||||||
}
|
return Ok(());
|
||||||
|
};
|
||||||
/// A Wayland session gets no backend (see the module docs). No X11 grab as a
|
let hotkey: HotKey = binding
|
||||||
/// fallback either: it succeeds inside a Wayland session and then only fires
|
.to_string()
|
||||||
/// while an XWayland window has focus, which looks broken.
|
.parse()
|
||||||
fn choose_backend() -> Result<Backend, String> {
|
.map_err(|e| format!("{} is not a usable shortcut: {}", binding, e))?;
|
||||||
if crate::activate::raise::is_wayland() {
|
let manager = GlobalHotKeyManager::new()
|
||||||
return Ok(Backend::Idle);
|
.map_err(|e| format!("global shortcuts are unavailable: {}", e))?;
|
||||||
}
|
manager.register(hotkey).map_err(|e| match e {
|
||||||
GlobalHotKeyManager::new()
|
global_hotkey::Error::AlreadyRegistered(_) => {
|
||||||
.map(|manager| Backend::Grab {
|
format!("another application is already using {}", binding)
|
||||||
|
}
|
||||||
|
other => format!("{} could not be registered: {}", binding, other),
|
||||||
|
})?;
|
||||||
|
*self = Backend::Grab {
|
||||||
manager,
|
manager,
|
||||||
registered: None,
|
registered: hotkey,
|
||||||
})
|
};
|
||||||
.map_err(|e| format!("global shortcuts are unavailable: {}", e))
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -226,5 +212,15 @@ mod tests {
|
||||||
backend.register(Some("Ctrl+Shift+F".parse().unwrap())),
|
backend.register(Some("Ctrl+Shift+F".parse().unwrap())),
|
||||||
Ok(())
|
Ok(())
|
||||||
);
|
);
|
||||||
|
assert!(matches!(backend, Backend::Idle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holding no key must not start a manager: on X11 its thread polls the
|
||||||
|
/// display until it is dropped.
|
||||||
|
#[test]
|
||||||
|
fn releasing_holds_no_manager() {
|
||||||
|
let mut backend = Backend::Ready;
|
||||||
|
assert_eq!(backend.register(None), Ok(()));
|
||||||
|
assert!(matches!(backend, Backend::Ready));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,6 @@ impl SearchTab {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match update {
|
match update {
|
||||||
SearchUpdate::Started { .. } => {}
|
|
||||||
SearchUpdate::Hits { hits, .. } => {
|
SearchUpdate::Hits { hits, .. } => {
|
||||||
// An empty batch is not a result; the cascade never sends one,
|
// An empty batch is not a result; the cascade never sends one,
|
||||||
// but the event is public and this is what it would mean.
|
// but the event is public and this is what it would mean.
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,11 @@ fn timed_frame(ctx: &egui::Context, tab: &mut SearchTab) {
|
||||||
/// Per-visible-row frame cost, printed rather than asserted. Measured: 1000
|
/// Per-visible-row frame cost, printed rather than asserted. Measured: 1000
|
||||||
/// rows with content snippets cost 615 µs/frame (<4% of a 60 fps budget), so
|
/// rows with content snippets cost 615 µs/frame (<4% of a 60 fps budget), so
|
||||||
/// the Content Match cut is not memoized; re-measure here before optimizing
|
/// the Content Match cut is not memoized; re-measure here before optimizing
|
||||||
/// the row renderer.
|
/// the row renderer. Re-measured for formatting each hit's size, date, rank
|
||||||
|
/// and flattened window once on arrival instead of every frame: rows cost
|
||||||
|
/// 231 µs/frame (names only) and 434 µs (content snippets), against about
|
||||||
|
/// 20 ms for a whole frame under software GL (llvmpipe), so caching could
|
||||||
|
/// save at most ~2% of a fade or cursor-blink frame. Not adopted.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// QSB_RENDER_PERF=1 cargo test --release -p quicksearch-gui -- render_perf --nocapture
|
/// QSB_RENDER_PERF=1 cargo test --release -p quicksearch-gui -- render_perf --nocapture
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue