Skip to main content

max / audiofiles

Add a headless checker for the blob layout migration The flat-to-sharded sweep auto-starts at vault open on any vault created before 2026-07-29, so its first real exercise lands on a library someone cares about. The unit tests cover the branches two or three blobs at a time; they do not answer whether it survives fifty thousand renames, whether an interrupted pass really resumes, or whether the mirror still points at anything afterwards. `audiofiles-bench -- layout` fabricates flat vaults and checks 22 properties across four scenarios: a full sweep, cancel and resume, a planted size mismatch next to a redundant copy, and the mirror. It exits non-zero on a failure, so it is a checker that happens to time a sweep rather than a benchmark with a baseline to defend. All 22 pass at 2,000 and at 50,000 blobs. The timed sweep reads about 99k blobs/s on ext4 over NVMe, which says nothing about exFAT over USB. Vaults are fabricated rather than imported, since the sweep reads the filesystem and not the DB, and a real import pass would only spend its time in hashing and per-file fsync. Blobs are left read-only exactly as import leaves them, because the dedup and mismatch branches unlink a flat blob and that is the permission shape they meet in the field. Deliberately not covered, and still an eyeball pass: the progress strip rendering above the footer, and browsing, preview and search staying usable mid-sweep. The store-layer half of that second claim is covered here, since every blob resolves through a partial migration.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 01:11 UTC
Signed with PGP, not checked
Commit: ca54ed060dd4f8d1eb29f769cad15b48be019a0a
Parent: 830ef9e
5 files changed, +542 insertions, -6 deletions
M Cargo.lock +2
@@ -416,8 +416,10 @@
416 416 dependencies = [
417 417 "audiofiles-core",
418 418 "rayon",
419 + "rusqlite",
419 420 "serde",
420 421 "serde_json",
422 + "tempfile",
421 423 ]
422 424
423 425 [[package]]
@@ -10,9 +10,15 @@
10 10 <mode>-<YYYY-MM-DD>-<short-sha>.json
11 11 ```
12 12
13 - `mode` is the bench mode (`ingest`, `analysis`, `accuracy`). The sha is the commit
14 - the binary was built from. Both matter: a filename alone should say what was
15 - measured and which code produced it.
13 + `mode` is the bench mode (`ingest`, `analysis`, `accuracy`, `layout`). The sha is
14 + the commit the binary was built from. Both matter: a filename alone should say what
15 + was measured and which code produced it.
16 +
17 + `layout` is the odd one out: it is a checker that happens to time a sweep, not a
18 + measurement with a baseline. It fabricates its own flat vaults, needs no corpus,
19 + and exits non-zero when a scenario fails, so it is worth running before touching
20 + anything under `store/`. Its throughput number moves with the drive rather than
21 + with the code, so it is not a figure to defend a change with.
16 22
17 23 ## Producing one
18 24
@@ -12,6 +12,12 @@
12 12 serde = { workspace = true }
13 13 serde_json = { workspace = true }
14 14 rayon = { workspace = true }
15 + # The layout mode fabricates `samples` rows directly rather than importing audio,
16 + # so it writes through the same connection the store does.
17 + rusqlite = { workspace = true }
18 +
19 + [dev-dependencies]
20 + tempfile = "3.25.0"
15 21
16 22 [lints]
17 23 workspace = true
@@ -10,11 +10,16 @@
10 10 //! `cargo run --release -p audiofiles-bench` analysis pipeline
11 11 //! `cargo run --release -p audiofiles-bench -- ingest` vault ingest + queries
12 12 //! `cargo run --release -p audiofiles-bench -- accuracy` bpm/key vs ground truth
13 + //! `cargo run --release -p audiofiles-bench -- layout` blob layout migration
14 + //!
15 + //! `layout` is the one mode that is a checker rather than a measurement: it
16 + //! fabricates flat vaults, sweeps them, and exits non-zero if any scenario fails.
13 17 //!
14 18 //! Env: `AF_BENCH_CORPUS` (corpus root, default `<repo>/samples`),
15 - //! `AF_BENCH_VAULT` (scratch vault for ingest), `AF_BENCH_FSL10K` (FSL10K root
16 - //! for accuracy), `AF_BENCH_BATCH`, `AF_BENCH_LIMIT`, `AF_BENCH_ANALYZE`,
17 - //! `AF_BENCH_JSON` (machine-readable output path).
19 + //! `AF_BENCH_VAULT` (scratch vault for ingest and layout), `AF_BENCH_FSL10K`
20 + //! (FSL10K root for accuracy), `AF_BENCH_BATCH`, `AF_BENCH_LIMIT`,
21 + //! `AF_BENCH_ANALYZE`, `AF_BENCH_LAYOUT_N`, `AF_BENCH_JSON` (machine-readable
22 + //! output path).
18 23 //!
19 24 //! Every mode opens with a conditions block naming the drive behind each path.
20 25 //! These numbers are I/O bound, so they belong to a drive as much as to the
@@ -22,6 +27,7 @@
22 27
23 28 mod accuracy;
24 29 mod ingest;
30 + mod layout;
25 31 mod report;
26 32 mod storage;
27 33
@@ -251,6 +257,22 @@
251 257 return;
252 258 }
253 259
260 + if args.first().map(String::as_str) == Some("layout") {
261 + // Its own default vault name, not the ingest one: this mode deletes and
262 + // rebuilds the vault five times per run, and pointing it at a corpus
263 + // vault someone had just imported would be an expensive surprise.
264 + let vault = std::env::var("AF_BENCH_VAULT").map_or_else(
265 + |_| std::env::temp_dir().join("af-bench-layout"),
266 + PathBuf::from,
267 + );
268 + let blobs = std::env::var("AF_BENCH_LAYOUT_N")
269 + .ok()
270 + .and_then(|v| v.parse().ok())
271 + .unwrap_or(layout::DEFAULT_TIMED_BLOBS);
272 + layout::run(&vault, blobs);
273 + return;
274 + }
275 +
254 276 if args.first().map(String::as_str) == Some("accuracy") {
255 277 let Ok(root) = std::env::var("AF_BENCH_FSL10K") else {
256 278 eprintln!("set AF_BENCH_FSL10K to the extracted FSL10K root");
@@ -1,0 +1,708 @@
1 + //! Flat-to-sharded blob migration, exercised headlessly at scale.
2 + //!
3 + //! The sweep in `audiofiles_core::store::layout` auto-starts at vault open on any
4 + //! vault created before 2026-07-29, so the first real exercise of it lands on a
5 + //! library someone cares about unless it is done deliberately first. Unit tests
6 + //! cover the branches on two or three blobs each; they do not answer whether the
7 + //! thing survives fifty thousand renames, whether an interrupted pass really
8 + //! resumes, or whether the mirror's symlinks are still pointing at anything
9 + //! afterwards.
10 + //!
11 + //! This mode fabricates flat vaults and falsifies those claims one at a time. It
12 + //! is a checker first and a benchmark second: every scenario prints PASS or FAIL
13 + //! and the process exits non-zero if any of them failed, so a run that scrolls
14 + //! past unread still fails loudly in a pipeline.
15 + //!
16 + //! What it cannot cover, and what still needs a human at the app: the progress
17 + //! strip's rendering above the footer, and browsing/preview/search staying usable
18 + //! mid-sweep. The store-layer half of that second claim is covered here (every
19 + //! blob resolves through a partial migration), but the GUI half is an eyeball
20 + //! pass.
21 + //!
22 + //! Vaults are fabricated rather than imported: the sweep reads the filesystem, not
23 + //! the DB, so blob provenance is irrelevant to it and a real import pass would
24 + //! spend its time in hashing and per-file fsync instead. Blobs are chmod'd
25 + //! read-only exactly as `import` leaves them, because two branches of the sweep
26 + //! unlink a flat blob and that is the permission shape they will meet in the
27 + //! field.
28 + //!
29 + //! Usage:
30 + //! `cargo run --release -p audiofiles-bench -- layout`
31 + //!
32 + //! Env: `AF_BENCH_VAULT` (scratch vault root, default `<tmp>/af-bench-layout`),
33 + //! `AF_BENCH_LAYOUT_N` (blobs in the timed sweep, default 50,000),
34 + //! `AF_BENCH_JSON` (machine-readable output path).
35 + //!
36 + //! <!-- wiki: af-benchmarks -->
37 +
38 + use std::path::{Path, PathBuf};
39 + use std::sync::atomic::{AtomicBool, Ordering};
40 + use std::time::Instant;
41 +
42 + use audiofiles_core::config_key::ConfigKey;
43 + use audiofiles_core::db::Database;
44 + use audiofiles_core::id_types::SampleHash;
45 + use audiofiles_core::store::layout::{
46 + BlobLayout, LayoutMigration, count_flat_blobs, migrate_to_sharded, recorded_layout,
47 + };
48 + use audiofiles_core::store::{
49 + SampleStore, existing_blob_path, hash_file, legacy_flat_blob_path, store_blob_path,
50 + };
51 + use audiofiles_core::vfs;
52 + use audiofiles_core::vfs_mirror::{MirrorConfig, sync_mirror};
53 +
54 + use crate::report::Report;
55 + use crate::storage;
56 +
57 + /// Blobs in each correctness scenario.
58 + ///
59 + /// Small on purpose: these check branches, not throughput, and every scenario
60 + /// rebuilds its vault from scratch. Large enough that a cancellation lands in the
61 + /// middle of a pass rather than racing the first blob.
62 + const SCENARIO_BLOBS: usize = 2_000;
63 +
64 + /// Blobs in the timed sweep unless `AF_BENCH_LAYOUT_N` says otherwise.
65 + ///
66 + /// Matches the af-bench-nsynth corpus on the T9 (53,286 blobs) closely enough to
67 + /// stand in for it, so the throughput number here is comparable to what that vault
68 + /// will do.
69 + pub(crate) const DEFAULT_TIMED_BLOBS: usize = 50_000;
70 +
71 + /// A fabricated blob: its content hash and extension.
72 + type Blob = (String, String);
73 +
74 + /// Deterministic blob content for index `i`.
75 + ///
76 + /// Length varies with the index so that a planted size mismatch is a real
77 + /// difference in bytes rather than a difference the sweep could only see by
78 + /// hashing, and so the fabricated vault is not one file repeated.
79 + fn payload(i: usize) -> Vec<u8> {
80 + let mut bytes = format!("af-bench-layout blob {i}\n").into_bytes();
81 + bytes.resize(64 + (i % 97) * 8, b'\0');
82 + bytes
83 + }
84 +
85 + /// Make a blob read-only, as `SampleStore::import` leaves every canonical blob.
86 + ///
87 + /// Best-effort for the same reason the store's own version is: a filesystem that
88 + /// rejects the chmod must not fail the run. Unlinking still works either way,
89 + /// since that needs write on the directory rather than on the file.
90 + fn set_readonly(path: &Path) {
91 + if let Ok(meta) = std::fs::metadata(path) {
92 + let mut perms = meta.permissions();
93 + #[cfg(unix)]
94 + {
95 + use std::os::unix::fs::PermissionsExt;
96 + perms.set_mode(0o444);
97 + }
98 + #[cfg(not(unix))]
99 + perms.set_readonly(true);
100 + let _ = std::fs::set_permissions(path, perms);
101 + }
102 + }
103 +
104 + /// A fabricated vault in the legacy flat layout.
105 + struct FlatVault {
106 + db: Database,
107 + store: SampleStore,
108 + blobs: Vec<Blob>,
109 + root: PathBuf,
110 + }
111 +
112 + /// Build a vault of `n` flat blobs at `vault`, clearing anything already there.
113 + ///
114 + /// Rows go into `samples` so the mirror and the resolver have something to read;
115 + /// VFS links are created only when asked, because they cost a statement per blob
116 + /// and only the mirror scenario needs them.
117 + fn fabricate(vault: &Path, n: usize, with_vfs: bool) -> Option<FlatVault> {
118 + if vault.exists()
119 + && let Err(e) = std::fs::remove_dir_all(vault)
120 + {
121 + eprintln!("could not clear scratch vault: {e}");
122 + return None;
123 + }
124 + let root = vault.join("samples");
125 + let stage = vault.join("stage");
126 + for dir in [&root, &stage] {
127 + if let Err(e) = std::fs::create_dir_all(dir) {
128 + eprintln!("could not create {}: {e}", dir.display());
129 + return None;
130 + }
131 + }
132 +
133 + let db = match Database::open(vault.join("audiofiles.db")) {
134 + Ok(db) => db,
135 + Err(e) => {
136 + eprintln!("Database::open failed (WAL unsupported on this fs?): {e}");
137 + return None;
138 + }
139 + };
140 + let store = match SampleStore::new(&root) {
141 + Ok(s) => s,
142 + Err(e) => {
143 + eprintln!("SampleStore::new failed: {e}");
144 + return None;
145 + }
146 + };
147 +
148 + let vfs_id = if with_vfs {
149 + match vfs::create_vfs(&db, "bench") {
150 + Ok(id) => Some(id),
151 + Err(e) => {
152 + eprintln!("could not create bench vfs: {e}");
153 + return None;
154 + }
155 + }
156 + } else {
157 + None
158 + };
159 +
160 + let mut blobs = Vec::with_capacity(n);
161 + // `.wav` because `hash_file` refuses anything `is_audio_file` does not
162 + // recognise. It hashes bytes rather than decoding them, so the extension is
163 + // the only part of "is this audio" that has to be true here.
164 + let staged = stage.join("blob.wav");
165 + // One transaction for the whole fabrication. Per-row commits would make
166 + // building a 50k vault slower than the sweep it exists to measure.
167 + if db.conn().execute_batch("BEGIN").is_err() {
168 + eprintln!("could not open the fabrication transaction");
169 + return None;
170 + }
171 + for i in 0..n {
172 + let content = payload(i);
173 + if std::fs::write(&staged, &content).is_err() {
174 + eprintln!("could not stage blob {i}");
175 + return None;
176 + }
177 + let (hash, size) = match hash_file(&staged) {
178 + Ok(pair) => pair,
179 + Err(e) => {
180 + eprintln!("could not hash blob {i}: {e}");
181 + return None;
182 + }
183 + };
184 + let dest = legacy_flat_blob_path(&root, &hash, "wav");
185 + if std::fs::rename(&staged, &dest).is_err() {
186 + eprintln!("could not place flat blob {i}");
187 + return None;
188 + }
189 + set_readonly(&dest);
190 + let now = i as i64;
191 + if db
192 + .conn()
193 + .execute(
194 + "INSERT OR IGNORE INTO samples
195 + (hash, original_name, file_extension, file_size, import_date, last_modified)
196 + VALUES (?1, ?2, 'wav', ?3, ?4, ?4)",
197 + rusqlite::params![hash, format!("blob-{i:06}.wav"), size, now],
198 + )
199 + .is_err()
200 + {
201 + eprintln!("could not insert sample row {i}");
202 + return None;
203 + }
204 + if let Some(vfs_id) = vfs_id
205 + && vfs::create_sample_link(
206 + &db,
207 + vfs_id,
208 + None,
209 + &format!("blob-{i:06}.wav"),
210 + &SampleHash::from_trusted(hash.clone()),
211 + )
212 + .is_err()
213 + {
214 + eprintln!("could not link blob {i} into the vfs");
215 + return None;
216 + }
217 + blobs.push((hash, "wav".to_string()));
218 + }
219 + if db.conn().execute_batch("COMMIT").is_err() {
220 + eprintln!("could not commit the fabrication transaction");
221 + return None;
222 + }
223 + let _ = std::fs::remove_dir_all(&stage);
224 +
225 + Some(FlatVault {
226 + db,
227 + store,
228 + blobs,
229 + root,
230 + })
231 + }
232 +
233 + /// Run a sweep with no cancellation and no progress interest.
234 + fn sweep(v: &FlatVault) -> Option<LayoutMigration> {
235 + let cancel = AtomicBool::new(false);
236 + migrate_to_sharded(&v.store, &v.db, &cancel, &mut |_, _| {})
237 + .inspect_err(|e| eprintln!("sweep failed: {e}"))
238 + .ok()
239 + }
240 +
241 + /// Every blob resolves to a file that exists, in whichever layout holds it.
242 + ///
243 + /// This is the store-layer half of "the library stays usable mid-migration": the
244 + /// resolver is what browsing, preview and export all go through, so a hash that
245 + /// stops resolving is a sample that has disappeared from the app.
246 + fn all_resolve(v: &FlatVault) -> bool {
247 + v.blobs
248 + .iter()
249 + .all(|(hash, ext)| existing_blob_path(&v.root, hash, ext).is_some_and(|p| p.is_file()))
250 + }
251 +
252 + /// Every blob sits at its canonical sharded path and nowhere else.
253 + fn all_sharded(v: &FlatVault) -> bool {
254 + v.blobs.iter().all(|(hash, ext)| {
255 + store_blob_path(&v.root, hash, ext).is_file()
256 + && !legacy_flat_blob_path(&v.root, hash, ext).exists()
257 + })
258 + }
259 +
260 + /// Names directly in the store root that are not shard directories.
261 + fn root_strays(root: &Path) -> Vec<String> {
262 + let Ok(entries) = std::fs::read_dir(root) else {
263 + return Vec::new();
264 + };
265 + entries
266 + .flatten()
267 + .filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
268 + .map(|e| e.file_name().to_string_lossy().into_owned())
269 + .collect()
270 + }
271 +
272 + /// Collected scenario results, printed as one table and folded into the exit code.
273 + struct Checks {
274 + rows: Vec<(String, bool, String)>,
275 + }
276 +
277 + impl Checks {
278 + fn new() -> Self {
279 + Self { rows: Vec::new() }
280 + }
281 +
282 + fn add(&mut self, name: &str, ok: bool, detail: impl Into<String>) {
283 + let detail = detail.into();
284 + println!(
285 + " {:<44} {} {detail}",
286 + name,
287 + if ok { "PASS" } else { "FAIL" }
288 + );
289 + self.rows.push((name.to_string(), ok, detail));
290 + }
291 +
292 + fn failed(&self) -> usize {
293 + self.rows.iter().filter(|(_, ok, _)| !ok).count()
294 + }
295 + }
296 +
297 + /// A pass over a complete vault relocates everything and records the layout.
298 + fn scenario_full(vault: &Path, checks: &mut Checks) {
299 + let Some(v) = fabricate(vault, SCENARIO_BLOBS, false) else {
300 + checks.add("full sweep", false, "could not fabricate the vault");
301 + return;
302 + };
303 + checks.add(
304 + "pending before the sweep",
305 + !matches!(recorded_layout(&v.db), Ok(BlobLayout::Sharded))
306 + && count_flat_blobs(&v.root).unwrap_or(0) == SCENARIO_BLOBS,
307 + format!("{SCENARIO_BLOBS} flat blobs, layout unrecorded"),
308 + );
309 +
310 + // Progress is what the strip draws, so its shape is worth checking even
311 + // though the strip itself is not on screen here: a callback that skips the
312 + // final call or walks backwards would show a bar that never fills.
313 + let mut seen: Vec<(usize, usize)> = Vec::new();
314 + let cancel = AtomicBool::new(false);
315 + let Ok(report) = migrate_to_sharded(&v.store, &v.db, &cancel, &mut |done, total| {
316 + seen.push((done, total));
317 + }) else {
318 + checks.add("full sweep", false, "sweep returned an error");
319 + return;
320 + };
321 +
322 + checks.add(
323 + "full sweep relocates every blob",
324 + report.moved == SCENARIO_BLOBS
325 + && report.deduped == 0
326 + && report.errors == 0
327 + && report.completed
328 + && !report.cancelled,
329 + format!(
330 + "moved {} deduped {} errors {}",
331 + report.moved, report.deduped, report.errors
332 + ),
333 + );
334 + checks.add(
335 + "progress is monotonic and ends full",
336 + seen.windows(2).all(|w| w[1].0 == w[0].0 + 1)
337 + && seen.last() == Some(&(SCENARIO_BLOBS, SCENARIO_BLOBS)),
338 + format!("{} callbacks", seen.len()),
339 + );
340 + checks.add(
341 + "blobs land under their hash prefix",
342 + all_sharded(&v),
343 + "{root}/{ab}/{hash}.wav".to_string(),
344 + );
345 + let strays = root_strays(&v.root);
346 + checks.add(
347 + "root holds only shard directories",
348 + strays.is_empty(),
349 + if strays.is_empty() {
350 + "clean".to_string()
351 + } else {
352 + format!("{} left: {}", strays.len(), strays.join(", "))
353 + },
354 + );
355 + checks.add(
356 + "layout recorded as sharded",
357 + matches!(recorded_layout(&v.db), Ok(BlobLayout::Sharded)),
358 + "blob_layout=sharded".to_string(),
359 + );
360 +
361 + // The second open the task asks about: a completed vault must not re-sweep.
362 + let Some(again) = sweep(&v) else {
363 + checks.add(
364 + "re-open does not re-sweep",
365 + false,
366 + "sweep returned an error",
367 + );
368 + return;
369 + };
370 + checks.add(
371 + "re-open does not re-sweep",
372 + again.moved == 0 && again.deduped == 0 && again.errors == 0 && again.completed,
373 + format!("moved {} on the second pass", again.moved),
374 + );
375 + }
376 +
377 + /// Cancelling mid-pass leaves a resolvable vault that the next pass finishes.
378 + fn scenario_resume(vault: &Path, checks: &mut Checks) {
379 + let Some(v) = fabricate(vault, SCENARIO_BLOBS, false) else {
380 + checks.add("resume", false, "could not fabricate the vault");
381 + return;
382 + };
383 +
384 + // Cancel from inside the progress callback, which is where the GUI's cancel
385 + // button effectively lands: the flag is set while the sweep is running.
386 + let cancel = AtomicBool::new(false);
387 + let stop_at = SCENARIO_BLOBS / 3;
388 + let Ok(first) = migrate_to_sharded(&v.store, &v.db, &cancel, &mut |done, _| {
389 + if done >= stop_at {
390 + cancel.store(true, Ordering::Release);
391 + }
392 + }) else {
393 + checks.add("resume", false, "first pass returned an error");
394 + return;
395 + };
396 +
397 + checks.add(
398 + "cancel stops the pass early",
399 + first.cancelled
400 + && !first.completed
401 + && first.moved >= stop_at
402 + && first.moved < SCENARIO_BLOBS,
403 + format!("moved {} of {SCENARIO_BLOBS} then stopped", first.moved),
404 + );
405 + checks.add(
406 + "cancelled vault stays recorded flat",
407 + matches!(recorded_layout(&v.db), Ok(BlobLayout::Flat)),
408 + "so the next open resumes".to_string(),
409 + );
410 + checks.add(
411 + "every blob resolves mid-migration",
412 + all_resolve(&v),
413 + "reads span both layouts".to_string(),
414 + );
415 +
416 + let remaining = count_flat_blobs(&v.root).unwrap_or(0);
417 + let Some(second) = sweep(&v) else {
418 + checks.add("resume", false, "second pass returned an error");
419 + return;
420 + };
421 + checks.add(
422 + "the next pass resumes rather than restarts",
423 + second.moved == remaining && first.moved + second.moved == SCENARIO_BLOBS,
424 + format!("{} + {} = {SCENARIO_BLOBS}", first.moved, second.moved),
425 + );
426 + checks.add(
427 + "resumed vault ends fully sharded",
428 + second.completed && all_sharded(&v) && root_strays(&v.root).is_empty(),
429 + "blob_layout=sharded".to_string(),
430 + );
431 + }
432 +
433 + /// A size mismatch is left for a human; a matching duplicate is discarded.
434 + fn scenario_mismatch(vault: &Path, checks: &mut Checks) {
435 + let Some(v) = fabricate(vault, 32, false) else {
436 + checks.add("size mismatch", false, "could not fabricate the vault");
437 + return;
438 + };
439 +
440 + // Plant a shard-side copy of blob 0 with different bytes (a truncated blob
441 + // from a pre-atomic-rename crash) and an identical-size copy of blob 1 (the
442 + // same content already migrated by a repair write).
443 + let (mismatch, ext) = v.blobs[0].clone();
444 + let (dupe, _) = v.blobs[1].clone();
445 + for (hash, content) in [(&mismatch, b"short".to_vec()), (&dupe, payload(1))] {
446 + let dest = store_blob_path(&v.root, hash, &ext);
447 + let Some(shard) = dest.parent() else { continue };
448 + if std::fs::create_dir_all(shard).is_err() || std::fs::write(&dest, &content).is_err() {
449 + checks.add("size mismatch", false, "could not plant the shard copy");
450 + return;
451 + }
452 + }
453 + // Leftovers the sweep must ignore rather than rename: an import that died
454 + // between create and rename, and a file a user dropped in by hand.
455 + let tmp = v.root.join(format!("{mismatch}.wav.12345.tmp"));
456 + let note = v.root.join("notes.txt");
457 + let _ = std::fs::write(&tmp, b"partial");
458 + let _ = std::fs::write(&note, b"mine");
459 +
460 + let Some(report) = sweep(&v) else {
461 + checks.add("size mismatch", false, "sweep returned an error");
462 + return;
463 + };
464 +
465 + checks.add(
466 + "size mismatch is counted, not resolved",
467 + report.errors == 1 && !report.completed,
468 + format!("errors {} completed {}", report.errors, report.completed),
469 + );
470 + checks.add(
471 + "both copies of a mismatch survive",
472 + legacy_flat_blob_path(&v.root, &mismatch, &ext).is_file()
473 + && store_blob_path(&v.root, &mismatch, &ext).is_file(),
474 + "left for inspection".to_string(),
475 + );
476 + checks.add(
477 + "redundant flat copy is discarded",
478 + report.deduped == 1 && !legacy_flat_blob_path(&v.root, &dupe, &ext).exists(),
479 + format!("deduped {}", report.deduped),
480 + );
481 + checks.add(
482 + "an errored pass stays recorded flat",
483 + matches!(recorded_layout(&v.db), Ok(BlobLayout::Flat)),
484 + "so the mismatch is swept again after repair".to_string(),
485 + );
486 + checks.add(
487 + "temp leftovers and stray files are untouched",
488 + tmp.is_file() && note.is_file(),
489 + "not blobs, never renamed".to_string(),
490 + );
491 + }
492 +
493 + /// The mirror's symlinks are stale after a sweep and whole again after a rebuild.
494 + fn scenario_mirror(vault: &Path, checks: &mut Checks) {
495 + let Some(v) = fabricate(vault, 256, true) else {
496 + checks.add("mirror", false, "could not fabricate the vault");
497 + return;
498 + };
499 + let mirror_root = vault.join("mirror");
500 + let config = MirrorConfig {
Lines truncated