|
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(¬e, 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 {
|