Skip to content

Commit 7a474fd

Browse files
committed
test(bench): add SqliteSearchRepository::search FTS5 latency benchmark
Adds bench_fts benchmark group measuring `search` on a 100-row seeded DB. Setup spawns SqliteWriter (which migrates schema + installs FTS5 triggers), then seeds 100 rows directly into `search_content` via raw rusqlite (mirroring the seed_via_conn pattern in crates/app/src/search.rs lines 202-223 with #[allow(clippy::disallowed_methods)]). iter_batched + BatchSize::LargeInput amortizes the writer-spawn + 100-INSERT setup cost over multiple search iterations. WHY direct INSERT into search_content (not files/tags/file_tags via public-API repos): SearchRepository::search queries the search_index FTS5 virtual table, which is populated by triggers ON search_content. Multi-table seeding into the source tables would silently leave search_index empty and the bench would measure a cold-miss path. sample_size = 30 (criterion default = 100). Local 10-sample smoke completed in ~30 sec without error; SMOKE_HITS=20 confirms FTS5 trigger fans out to search_index and the search returns the limit. Cargo.toml: criterion = "0.5" added narrowly to perima-db's [dev-dependencies] (not workspace-wide per spec D-12). [[bench]] entry uses harness = false.
1 parent a90ae6b commit 7a474fd

3 files changed

Lines changed: 131 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/db/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ insta.workspace = true
3838
# pattern for this (used by serde, tokio, etc.) — it's surprising the
3939
# first time you see it but standard.
4040
perima-db = { path = ".", features = ["test-utils"] }
41+
# WHY criterion: slice 4 of T1 test-architecture decomposition adds
42+
# FTS5 search-latency benchmark on a seeded SQLite DB. Observability-
43+
# only per spec D-4.
44+
criterion = "0.5"
45+
46+
[[bench]]
47+
name = "fts"
48+
harness = false # WHY: criterion installs its own harness; libtest off.
4149

4250
[lints]
4351
workspace = true

crates/db/benches/fts.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! `SqliteSearchRepository::search` FTS5 latency benchmark.
2+
//!
3+
//! Seeds a `SQLite` DB with 100 rows in `search_content` via raw rusqlite
4+
//! (mirroring `crates/app/src/search.rs::seed_via_conn` lines 202-223 —
5+
//! the canonical working pattern). The V007 FTS5 trigger on
6+
//! `search_content` fans out to `search_index`, which is what
7+
//! `SqliteSearchRepository::search` queries.
8+
//!
9+
//! Slice 4 of T1 test-architecture decomposition. Observability-only —
10+
//! the workflow prints results; no threshold gate.
11+
12+
// WHY allow(missing_docs): workspace `#![warn(missing_docs)]` plus the
13+
// `-D warnings` clippy gate trips on `criterion_group!`'s macro
14+
// expansion (the generated `benches` const + helpers are undocumented
15+
// by design). Bench files are not part of the public API.
16+
#![allow(missing_docs)]
17+
18+
use std::sync::Arc;
19+
20+
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
21+
// WHY both trait imports: `SearchRepository::search` is a trait method;
22+
// without `SearchRepository` in scope, `state.search_repo.search(...)`
23+
// fails method-resolution. `EventBus` import same reason for
24+
// `Arc<dyn EventBus>`.
25+
use perima_core::{EventBus, SearchRepository};
26+
use perima_db::SqliteSearchRepository;
27+
use perima_db::pool::ReadPool;
28+
use perima_db::test_utils::noop_bus::NoopBus;
29+
use perima_db::writer::{SqliteWriter, SqliteWriterHandle};
30+
31+
const SEED_ROW_COUNT: usize = 100;
32+
33+
fn bench_fts(c: &mut Criterion) {
34+
let mut group = c.benchmark_group("fts/search");
35+
36+
group.bench_function("search_q_match_half_rows", |b| {
37+
// WHY iter_batched + LargeInput: setup_db is expensive (writer
38+
// spawn + 100 INSERTs + refinery migration). LargeInput batches
39+
// multiple iterations per setup, amortizing the spawn cost.
40+
b.iter_batched(
41+
setup_db,
42+
|state| {
43+
let _ = state.search_repo.search("tag_42", 20);
44+
},
45+
BatchSize::LargeInput,
46+
);
47+
});
48+
49+
group.finish();
50+
}
51+
52+
struct BenchState {
53+
_td: tempfile::TempDir,
54+
_writer: SqliteWriterHandle,
55+
search_repo: SqliteSearchRepository,
56+
}
57+
58+
fn setup_db() -> BenchState {
59+
let td = tempfile::tempdir().expect("tempdir");
60+
let db_path = td.path().join("bench.db");
61+
let bus: Arc<dyn EventBus> = Arc::new(NoopBus);
62+
63+
// Spawn the writer — its `start()` runs the refinery migrations,
64+
// installs the FTS5 triggers, and is the sole writable Connection
65+
// owner per Batch C invariants.
66+
let writer = SqliteWriter::start(&db_path, bus).expect("writer start");
67+
let reads = ReadPool::open(&db_path).expect("pool open");
68+
let search_repo = SqliteSearchRepository::new(writer.sender(), reads);
69+
70+
seed_search_content(&db_path);
71+
72+
BenchState {
73+
_td: td,
74+
_writer: writer,
75+
search_repo,
76+
}
77+
}
78+
79+
#[allow(clippy::disallowed_methods)] // WHY: bench-only seed path, mirrors
80+
// crates/app/src/search.rs::seed_via_conn.
81+
fn seed_search_content(db_path: &std::path::Path) {
82+
use rusqlite::{Connection, params};
83+
// WHY Connection::open (NOT open_with_flags + RW|NO_MUTEX): mirrors
84+
// seed_via_conn line 212 verbatim — Connection::open opens RW by
85+
// default and is the proven pattern for this seed shape.
86+
let conn = Connection::open(db_path).expect("open rw");
87+
88+
// V007 search_content schema:
89+
// (blake3_hash, filename, relative_path, mime_type,
90+
// camera_model, captured_at, tags)
91+
// The FTS5 trigger on search_content fans out to search_index;
92+
// SearchRepository::search queries search_index. Sprinkle "tag_42"
93+
// into the tags column for ~half the rows so the query has a
94+
// non-trivial result set.
95+
let mut stmt = conn
96+
.prepare(
97+
"INSERT INTO search_content \
98+
(blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) \
99+
VALUES (?1, ?2, ?3, ?4, '', '', ?5)",
100+
)
101+
.expect("prepare");
102+
for i in 0..SEED_ROW_COUNT {
103+
let hash = format!("{i:064x}");
104+
let name = format!("file_{i}.jpg");
105+
let rel = format!("photos/{name}");
106+
let mime = "image/jpeg";
107+
let tags = if i % 2 == 0 {
108+
"tag_42 other"
109+
} else {
110+
"different"
111+
};
112+
stmt.execute(params![hash, name, rel, mime, tags])
113+
.expect("insert search_content");
114+
}
115+
}
116+
117+
criterion_group!(
118+
name = benches;
119+
config = Criterion::default().sample_size(30);
120+
targets = bench_fts
121+
);
122+
criterion_main!(benches);

0 commit comments

Comments
 (0)