max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1023 insertions,
-41 deletions
| @@ -5,11 +5,12 @@ | |||
| 5 | 5 | //! query latency that backs the browser UI. | |
| 6 | 6 | //! | |
| 7 | 7 | //! Two properties of the store make scale worth measuring rather than | |
| 8 | - | //! assuming. Blobs live in one flat directory (`store_blob_path` is | |
| 9 | - | //! `root.join("{hash}.{ext}")`, no fanout), so a 100k-sample vault is 100k | |
| 10 | - | //! entries in a single directory, and directory-lookup cost is filesystem | |
| 11 | - | //! dependent. And the DB runs in WAL mode with several worker connections, so | |
| 12 | - | //! insert cost moves with index depth. | |
| 8 | + | //! assuming. Blobs are sharded one level deep on the hash prefix | |
| 9 | + | //! (`{root}/{ab}/{hash}.{ext}`, 256 leaves), which is what a run on this bench | |
| 10 | + | //! bought: the flat layout it replaced lost about 90% of its import throughput | |
| 11 | + | //! between an empty vault and a 40,000-entry one. Directory cost is filesystem | |
| 12 | + | //! dependent either way, so it stays worth measuring. And the DB runs in WAL mode | |
| 13 | + | //! with several worker connections, so insert cost moves with index depth. | |
| 13 | 14 | //! | |
| 14 | 15 | //! Reported per batch rather than as one average, because the number that | |
| 15 | 16 | //! matters is whether throughput is flat or degrading as the vault grows. | |
| @@ -117,6 +118,25 @@ | |||
| 117 | 118 | .unwrap_or(-1) | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 121 | + | /// Count blobs anywhere under the store root, descending into shard directories. | |
| 122 | + | /// | |
| 123 | + | /// Must recurse. Blobs live at `{root}/{ab}/{hash}.{ext}`, so counting the root's | |
| 124 | + | /// own entries returns the number of shard directories (at most 256) rather than | |
| 125 | + | /// the number of blobs, which would make the dedup check below report "unchanged" | |
| 126 | + | /// no matter what the store did. | |
| 127 | + | fn count_blobs(root: &Path) -> usize { | |
| 128 | + | let Ok(entries) = std::fs::read_dir(root) else { | |
| 129 | + | return 0; | |
| 130 | + | }; | |
| 131 | + | entries | |
| 132 | + | .filter_map(std::result::Result::ok) | |
| 133 | + | .map(|e| { | |
| 134 | + | let path = e.path(); | |
| 135 | + | if path.is_dir() { count_blobs(&path) } else { 1 } | |
| 136 | + | }) | |
| 137 | + | .sum() | |
| 138 | + | } | |
| 139 | + | ||
| 120 | 140 | /// Time a query, returning milliseconds. Runs it `reps` times and takes the | |
| 121 | 141 | /// median, since a single cold query mostly measures page-cache state. | |
| 122 | 142 | fn time_query(reps: usize, mut f: impl FnMut()) -> f64 { | |
| @@ -409,7 +429,7 @@ | |||
| 409 | 429 | println!(); | |
| 410 | 430 | println!("━━━ DEDUP (re-import of identical content) ━━━"); | |
| 411 | 431 | println!(); | |
| 412 | - | let blobs_before = std::fs::read_dir(&samples_dir).map_or(0, std::iter::Iterator::count); | |
| 432 | + | let blobs_before = count_blobs(&samples_dir); | |
| 413 | 433 | let rows_before = count_samples(&db); | |
| 414 | 434 | ||
| 415 | 435 | let redo: Vec<&PathBuf> = files.iter().take(batch.min(files.len())).collect(); | |
| @@ -419,7 +439,7 @@ | |||
| 419 | 439 | } | |
| 420 | 440 | let redo_s = start.elapsed().as_secs_f64(); | |
| 421 | 441 | ||
| 422 | - | let blobs_after = std::fs::read_dir(&samples_dir).map_or(0, std::iter::Iterator::count); | |
| 442 | + | let blobs_after = count_blobs(&samples_dir); | |
| 423 | 443 | let rows_after = count_samples(&db); | |
| 424 | 444 | ||
| 425 | 445 | println!( |
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | pub mod export; | |
| 14 | 14 | pub mod import; | |
| 15 | 15 | pub mod instrument; | |
| 16 | + | pub mod layout_migration; | |
| 16 | 17 | pub mod loose_files_worker; | |
| 17 | 18 | pub mod preview; | |
| 18 | 19 | pub mod state; |
| @@ -115,6 +115,12 @@ | |||
| 115 | 115 | MirrorPath => "mirror_path": Local, | |
| 116 | 116 | /// Suppresses the local import safety preflight. Local safety gate. | |
| 117 | 117 | ImportPreflightDisabled => "import_preflight_disabled": Local, | |
| 118 | + | /// On-disk layout of this vault's blob directory (`flat` or `sharded`, absent | |
| 119 | + | /// meaning flat). Local, and load-bearingly so: it describes where THIS | |
| 120 | + | /// machine's bytes physically sit, and each device migrates on its own | |
| 121 | + | /// schedule, so replicating it would tell a peer its blobs live in a layout | |
| 122 | + | /// it has not moved to and make every lookup miss. | |
| 123 | + | BlobLayout => "blob_layout": Local, | |
| 118 | 124 | } | |
| 119 | 125 | ||
| 120 | 126 | impl ConfigKey { |
| @@ -14,7 +14,7 @@ | |||
| 14 | 14 | use crate::SampleHash; | |
| 15 | 15 | use crate::db::Database; | |
| 16 | 16 | use crate::error::{Result, io_err}; | |
| 17 | - | use crate::store::{sample_extension, sample_location, sample_source_path}; | |
| 17 | + | use crate::store::{sample_extension, sample_location_resolved, sample_source_path}; | |
| 18 | 18 | use crate::vfs::{NodeType, list_full_tree}; | |
| 19 | 19 | ||
| 20 | 20 | /// Configuration for the mirror directory. | |
| @@ -95,7 +95,11 @@ | |||
| 95 | 95 | // samples at the store blob. Hand-rolling `store_root.join` | |
| 96 | 96 | // here is exactly what produced dangling symlinks for | |
| 97 | 97 | // loose-files samples (the blob never exists for them). | |
| 98 | - | let target = sample_location(&config.store_root, hash, ext, source).into_path(); | |
| 98 | + | // The *resolved* variant, because a vault mid-layout-migration | |
| 99 | + | // still holds blobs at the legacy flat path and the pure | |
| 100 | + | // mapping would link every one of them into the void. | |
| 101 | + | let target = | |
| 102 | + | sample_location_resolved(&config.store_root, hash, ext, source).into_path(); | |
| 99 | 103 | ||
| 100 | 104 | // Two distinct samples can sanitize to the same display | |
| 101 | 105 | // path in one directory. The first wins the bare name; the |
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | use crate::error::{CoreError, Result, io_err, unix_now}; | |
| 26 | 26 | use tracing::instrument; | |
| 27 | 27 | ||
| 28 | + | pub mod layout; | |
| 28 | 29 | mod loose_files; | |
| 29 | 30 | pub use loose_files::*; | |
| 30 | 31 | ||
| @@ -112,8 +113,13 @@ | |||
| 112 | 113 | } | |
| 113 | 114 | } | |
| 114 | 115 | ||
| 115 | - | /// Manages on-disk sample blobs in a flat directory structure, storing files as | |
| 116 | - | /// `{sha256_hex}.{ext}` directly in the root directory. | |
| 116 | + | /// Manages on-disk sample blobs, storing files as `{sha256_hex}.{ext}` under a | |
| 117 | + | /// two-hex-character shard directory taken from the hash: `{root}/{ab}/{hash}.{ext}`. | |
| 118 | + | /// | |
| 119 | + | /// Vaults written before 2026-07-29 use a flat root instead. Reads resolve either | |
| 120 | + | /// ([`sample_path`](Self::sample_path)), writes always target the sharded layout, | |
| 121 | + | /// and [`layout::migrate_to_sharded`] relocates a vault forward. See [`layout`] for | |
| 122 | + | /// why the flat layout had to go. | |
| 117 | 123 | /// | |
| 118 | 124 | /// Deduplication via SHA-256: import streams the file through a hasher and skips | |
| 119 | 125 | /// the copy if a blob with the same hash already exists. | |
| @@ -190,12 +196,26 @@ | |||
| 190 | 196 | // Mirrors the atomic temp+rename the sync download path uses. The pid | |
| 191 | 197 | // suffix keeps two concurrent imports of the same hash from colliding on | |
| 192 | 198 | // the temp file. | |
| 193 | - | let dest = self.sample_path(hash, &ext)?; | |
| 194 | - | let needs_write = match fs::metadata(&dest) { | |
| 195 | - | Ok(m) => m.len() != file_size as u64, | |
| 196 | - | Err(_) => true, | |
| 199 | + | // Writes go to the canonical sharded path, but an intact blob at the | |
| 200 | + | // *legacy flat* path satisfies the import just as well: a vault mid- | |
| 201 | + | // migration still holds blobs there, and writing a second sharded copy of | |
| 202 | + | // one would double the disk for no gain. So the presence check spans both | |
| 203 | + | // layouts while the write target stays canonical, which is what makes an | |
| 204 | + | // unmigrated vault converge as it is used instead of growing. | |
| 205 | + | let dest = self.blob_write_path(hash, &ext)?; | |
| 206 | + | let existing = existing_blob_path(&self.root, hash.as_str(), &ext); | |
| 207 | + | let needs_write = match existing.as_ref().and_then(|p| fs::metadata(p).ok()) { | |
| 208 | + | Some(m) => m.len() != file_size as u64, | |
| 209 | + | None => true, | |
| 197 | 210 | }; | |
| 198 | 211 | if needs_write { | |
| 212 | + | // The shard directory is created lazily on first write into it. One | |
| 213 | + | // extra stat per import against a directory that almost always exists | |
| 214 | + | // (256 of them at most), which is far cheaper than the flat-directory | |
| 215 | + | // scan the sharding removes. | |
| 216 | + | if let Some(shard_dir) = dest.parent() { | |
| 217 | + | fs::create_dir_all(shard_dir).map_err(|e| io_err(shard_dir, e))?; | |
| 218 | + | } | |
| 199 | 219 | let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id())); | |
| 200 | 220 | // Copy *and* hash the bytes in a single pass, fsyncing the temp | |
| 201 | 221 | // before the rename. `hash` was computed by an earlier pass (batch | |
| @@ -238,6 +258,14 @@ | |||
| 238 | 258 | // needs write on the directory, not the file). Best-effort: a | |
| 239 | 259 | // filesystem that rejects the chmod must not fail the import. | |
| 240 | 260 | set_blob_readonly(&dest); | |
| 261 | + | // A truncated copy at the legacy flat path has just been superseded by | |
| 262 | + | // the blob written at the canonical one. `sample_path` resolves sharded | |
| 263 | + | // first, so the flat copy is now unreachable and would leak disk | |
| 264 | + | // forever. Unlinking it here also means a repair write migrates a blob | |
| 265 | + | // as a side effect, independent of the sweep. | |
| 266 | + | if let Some(stale) = existing.as_ref().filter(|p| p.as_path() != dest) { | |
| 267 | + | let _ = fs::remove_file(stale); | |
| 268 | + | } | |
| 241 | 269 | } | |
| 242 | 270 | ||
| 243 | 271 | // Insert into DB (ignore if hash already exists). If this fails after we | |
| @@ -268,17 +296,66 @@ | |||
| 268 | 296 | Ok(self.sample_path(hash, ext)?.exists()) | |
| 269 | 297 | } | |
| 270 | 298 | ||
| 271 | - | /// Get the filesystem path for a sample. | |
| 299 | + | /// Get the filesystem path for a sample: wherever its bytes currently are. | |
| 300 | + | /// | |
| 301 | + | /// Resolves the sharded layout first, then the legacy flat one, and falls back | |
| 302 | + | /// to the canonical sharded path when neither exists so a caller that opens it | |
| 303 | + | /// surfaces a clean "not found". Resolving rather than pure is deliberate: | |
| 304 | + | /// every read, verify and unlink site in the codebase already goes through | |
| 305 | + | /// here, so a vault that has not finished migrating keeps working without each | |
| 306 | + | /// of those sites having to know about the layout. [`blob_write_path`](Self::blob_write_path) | |
| 307 | + | /// is the non-resolving write target. | |
| 272 | 308 | /// | |
| 273 | 309 | /// Re-validates that `hash` is exactly 64 lowercase hex characters (SHA-256) | |
| 274 | 310 | /// as a defense-in-depth guard against a `from_trusted` slip that could form | |
| 275 | 311 | /// a directory-traversal or malformed path. | |
| 276 | 312 | pub fn sample_path(&self, hash: &SampleHash, ext: &str) -> Result<PathBuf> { | |
| 313 | + | validate_hash(hash.as_str())?; | |
| 314 | + | validate_extension(ext)?; | |
| 315 | + | Ok(existing_blob_path(&self.root, hash.as_str(), ext) | |
| 316 | + | .unwrap_or_else(|| store_blob_path(&self.root, hash.as_str(), ext))) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// Where new bytes for `hash` must be written: the canonical sharded path, | |
| 320 | + | /// whether or not anything is there. | |
| 321 | + | /// | |
| 322 | + | /// Distinct from [`sample_path`](Self::sample_path), which resolves to the | |
| 323 | + | /// blob's current location. Writes always target the canonical layout so an | |
| 324 | + | /// unmigrated vault converges as it is used rather than accumulating more flat | |
| 325 | + | /// blobs. Validates as `sample_path` does. | |
| 326 | + | pub fn blob_write_path(&self, hash: &SampleHash, ext: &str) -> Result<PathBuf> { | |
| 277 | 327 | validate_hash(hash.as_str())?; | |
| 278 | 328 | validate_extension(ext)?; | |
| 279 | 329 | Ok(store_blob_path(&self.root, hash.as_str(), ext)) | |
| 280 | 330 | } | |
| 281 | 331 | ||
| 332 | + | /// Unlink this blob from both layouts, ignoring absences. | |
| 333 | + | /// | |
| 334 | + | /// Every delete site uses this rather than unlinking the resolved path alone: | |
| 335 | + | /// resolving returns one path, so a vault that somehow holds the blob in both | |
| 336 | + | /// layouts (an interrupted sweep plus a repair write) would keep the other copy | |
| 337 | + | /// as an unreachable orphan that no GC path reclaims. Returns the first real | |
| 338 | + | /// error, having attempted both. | |
| 339 | + | fn unlink_blob_all_layouts(&self, hash: &SampleHash, ext: &str) -> Result<()> { | |
| 340 | + | validate_hash(hash.as_str())?; | |
| 341 | + | validate_extension(ext)?; | |
| 342 | + | let mut first_err = None; | |
| 343 | + | for path in [ | |
| 344 | + | store_blob_path(&self.root, hash.as_str(), ext), | |
| 345 | + | legacy_flat_blob_path(&self.root, hash.as_str(), ext), | |
| 346 | + | ] { | |
| 347 | + | match fs::remove_file(&path) { | |
| 348 | + | Ok(()) => {} | |
| 349 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | |
| 350 | + | Err(e) => first_err = first_err.or(Some(io_err(&path, e))), | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | match first_err { | |
| 354 | + | Some(e) => Err(e), | |
| 355 | + | None => Ok(()), | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 282 | 359 | /// Remove a sample from store and database. CASCADE handles VFS/tag refs. | |
| 283 | 360 | /// | |
| 284 | 361 | /// Deletes the file from disk first, then the DB row. If the file delete | |
| @@ -294,14 +371,10 @@ | |||
| 294 | 371 | // content-addressed and identical regardless of tombstone state, so the | |
| 295 | 372 | // unfiltered lookup is the correct one for locating the file to unlink. | |
| 296 | 373 | let ext = sample_extension_any(db, hash)?; | |
| 297 | - | let path = self.sample_path(hash, &ext)?; | |
| 298 | 374 | ||
| 299 | - | // File first. ENOENT is fine, the row was already pointing at nothing. | |
| 300 | - | match fs::remove_file(&path) { | |
| 301 | - | Ok(()) => {} | |
| 302 | - | Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | |
| 303 | - | Err(e) => return Err(io_err(&path, e)), | |
| 304 | - | } | |
| 375 | + | // File first, from both layouts. ENOENT is fine, the row was already | |
| 376 | + | // pointing at nothing. | |
| 377 | + | self.unlink_blob_all_layouts(hash, &ext)?; | |
| 305 | 378 | ||
| 306 | 379 | // Then the DB row (CASCADE handles tags, vfs_nodes, etc.). | |
| 307 | 380 | db.conn() | |
| @@ -372,11 +445,10 @@ | |||
| 372 | 445 | [hash], | |
| 373 | 446 | )?; | |
| 374 | 447 | if n > 0 { | |
| 375 | - | if let Ok(path) = self.sample_path(hash, ext) | |
| 376 | - | && path.exists() | |
| 377 | - | { | |
| 378 | - | let _ = fs::remove_file(&path); | |
| 379 | - | } | |
| 448 | + | // Both layouts: a vault mid-migration holds the blob at the | |
| 449 | + | // legacy flat path, and unlinking only the resolved one would | |
| 450 | + | // leave the other as an orphan this very sweep exists to stop. | |
| 451 | + | let _ = self.unlink_blob_all_layouts(hash, ext); | |
| 380 | 452 | count += 1; | |
| 381 | 453 | } | |
| 382 | 454 | } | |
| @@ -693,17 +765,75 @@ | |||
| 693 | 765 | } | |
| 694 | 766 | } | |
| 695 | 767 | ||
| 696 | - | /// Build the content-addressed store blob path for `hash`+`ext` under | |
| 697 | - | /// `store_root`. The single place `{store_root}/{hash}[.ext]` is constructed, no | |
| 698 | - | /// caller hand-rolls this join (it once drifted in the VFS mirror). | |
| 768 | + | /// Leading hex characters of the hash used as the blob's shard directory: one | |
| 769 | + | /// level of two chars, so 256 leaves. | |
| 770 | + | /// | |
| 771 | + | /// One level, not git's two. Every directory occupies at least one filesystem | |
| 772 | + | /// cluster, and the exFAT volumes sample libraries actually live on use 128 KiB | |
| 773 | + | /// clusters, so `ab/cd/` (65,536 leaves) would spend 8 GiB of directory clusters | |
| 774 | + | /// against a 36 GB library. One level costs 32 MiB and still holds only ~1,130 | |
| 775 | + | /// blobs per leaf at 289k samples, small enough that a leaf stays cheap to scan | |
| 776 | + | /// even on a filesystem with no directory index. Measured 2026-07-29; the numbers | |
| 777 | + | /// and the flat-layout collapse they replace are in wiki `af-benchmarks`. | |
| 778 | + | pub const BLOB_SHARD_HEX: usize = 2; | |
| 779 | + | ||
| 780 | + | /// The shard directory name for `hash`: its leading [`BLOB_SHARD_HEX`] characters. | |
| 781 | + | /// | |
| 782 | + | /// A SHA-256 is uniformly distributed, so a hash prefix is a balanced shard key | |
| 783 | + | /// for free and the store stays purely content-addressed: nothing outside the | |
| 784 | + | /// content decides where a blob lives. A hash shorter than the prefix yields the | |
| 785 | + | /// whole string, which cannot collide with a real 64-char blob name. | |
| 786 | + | #[must_use] | |
| 787 | + | pub fn blob_shard(hash: &str) -> &str { | |
| 788 | + | &hash[..hash.len().min(BLOB_SHARD_HEX)] | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | /// Build the canonical store blob path: `{store_root}/{ab}/{hash}[.ext]`. | |
| 792 | + | /// | |
| 793 | + | /// The single place the sharded layout is constructed, no caller hand-rolls this | |
| 794 | + | /// join (it once drifted in the VFS mirror). See [`legacy_flat_blob_path`] for the | |
| 795 | + | /// layout this replaced and [`existing_blob_path`] for reads that must tolerate | |
| 796 | + | /// both. | |
| 699 | 797 | pub fn store_blob_path(store_root: &Path, hash: &str, ext: &str) -> PathBuf { | |
| 798 | + | blob_path_in(&store_root.join(blob_shard(hash)), hash, ext) | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | /// Build the legacy flat blob path: `{store_root}/{hash}[.ext]`. | |
| 802 | + | /// | |
| 803 | + | /// The layout every vault created before 2026-07-29 uses. Kept as a read fallback | |
| 804 | + | /// so a vault that has not finished migrating still resolves, and as the source | |
| 805 | + | /// side of the relocation sweep in [`layout`]. Never a write target: writes go to | |
| 806 | + | /// [`store_blob_path`] so a vault converges as it is used. | |
| 807 | + | pub fn legacy_flat_blob_path(store_root: &Path, hash: &str, ext: &str) -> PathBuf { | |
| 808 | + | blob_path_in(store_root, hash, ext) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | /// Join a blob's filename onto `dir`. Shared by both layouts so the | |
| 812 | + | /// extension-vs-no-extension rule cannot diverge between them. | |
| 813 | + | fn blob_path_in(dir: &Path, hash: &str, ext: &str) -> PathBuf { | |
| 700 | 814 | if ext.is_empty() { | |
| 701 | - | store_root.join(hash) | |
| 815 | + | dir.join(hash) | |
| 702 | 816 | } else { | |
| 703 | - | store_root.join(format!("{hash}.{ext}")) | |
| 817 | + | dir.join(format!("{hash}.{ext}")) | |
| 704 | 818 | } | |
| 705 | 819 | } | |
| 706 | 820 | ||
| 821 | + | /// The blob's existing on-disk path, preferring the sharded layout and falling | |
| 822 | + | /// back to the legacy flat one. `None` when neither exists. | |
| 823 | + | /// | |
| 824 | + | /// Sharded is checked first so a fully migrated vault costs one stat, not two. | |
| 825 | + | pub fn existing_blob_path(store_root: &Path, hash: &str, ext: &str) -> Option<PathBuf> { | |
| 826 | + | let sharded = store_blob_path(store_root, hash, ext); | |
| 827 | + | if sharded.exists() { | |
| 828 | + | return Some(sharded); | |
| 829 | + | } | |
| 830 | + | let flat = legacy_flat_blob_path(store_root, hash, ext); | |
| 831 | + | if flat.exists() { | |
| 832 | + | return Some(flat); | |
| 833 | + | } | |
| 834 | + | None | |
| 835 | + | } | |
| 836 | + | ||
| 707 | 837 | /// Single source of truth for "where does this sample's bytes live": the | |
| 708 | 838 | /// loose-files `source_path` if the sample has one, otherwise the store blob. | |
| 709 | 839 | /// | |
| @@ -724,6 +854,28 @@ | |||
| 724 | 854 | } | |
| 725 | 855 | } | |
| 726 | 856 | ||
| 857 | + | /// [`sample_location`] with the dual-layout existence fallback applied to the | |
| 858 | + | /// store case: resolves to wherever the blob currently is, sharded or legacy flat. | |
| 859 | + | /// | |
| 860 | + | /// For callers that hold a `store_root` but no [`SampleStore`] (the VFS mirror) and | |
| 861 | + | /// must not emit a path that does not exist. A mirror built from the pure mapping | |
| 862 | + | /// against a vault mid-migration would point every symlink at a sharded blob that | |
| 863 | + | /// has not been relocated yet, which is the dangling-symlink failure the pure | |
| 864 | + | /// version's own doc comment warns about. Loose samples are unaffected. | |
| 865 | + | pub fn sample_location_resolved( | |
| 866 | + | store_root: &Path, | |
| 867 | + | hash: &str, | |
| 868 | + | ext: &str, | |
| 869 | + | source_path: Option<&str>, | |
| 870 | + | ) -> SampleLocation { | |
| 871 | + | match sample_location(store_root, hash, ext, source_path) { | |
| 872 | + | SampleLocation::Store(canonical) => { | |
| 873 | + | SampleLocation::Store(existing_blob_path(store_root, hash, ext).unwrap_or(canonical)) | |
| 874 | + | } | |
| 875 | + | loose @ SampleLocation::Loose(_) => loose, | |
| 876 | + | } | |
| 877 | + | } | |
| 878 | + | ||
| 727 | 879 | /// Resolve the actual file path for a sample, checking source_path first. | |
| 728 | 880 | /// | |
| 729 | 881 | /// Builds on [`sample_location`] (the canonical loose-vs-store decision) and adds | |
| @@ -893,6 +1045,28 @@ | |||
| 893 | 1045 | path | |
| 894 | 1046 | } | |
| 895 | 1047 | ||
| 1048 | + | /// Count every file anywhere under `root`, shard directories included. | |
| 1049 | + | /// | |
| 1050 | + | /// Blob-count assertions must not stop at the root's own entries: under the | |
| 1051 | + | /// sharded layout that reads 0 whatever the store actually holds, which would | |
| 1052 | + | /// turn an orphan-detection test into one that cannot fail. | |
| 1053 | + | fn count_blobs_recursively(root: &Path) -> usize { | |
| 1054 | + | let Ok(entries) = fs::read_dir(root) else { | |
| 1055 | + | return 0; | |
| 1056 | + | }; | |
| 1057 | + | entries | |
| 1058 | + | .filter_map(std::result::Result::ok) | |
| 1059 | + | .map(|e| { | |
| 1060 | + | let path = e.path(); | |
| 1061 | + | if path.is_dir() { | |
| 1062 | + | count_blobs_recursively(&path) | |
| 1063 | + | } else { | |
| 1064 | + | 1 | |
| 1065 | + | } | |
| 1066 | + | }) | |
| 1067 | + | .sum() | |
| 1068 | + | } | |
| 1069 | + | ||
| 896 | 1070 | /// Give a sample one VFS placement, so CASCADE behaviour and placement | |
| 897 | 1071 | /// preservation are observable. | |
| 898 | 1072 | fn place_sample(db: &Database, hash: &str) { | |
| @@ -1249,14 +1423,10 @@ | |||
| 1249 | 1423 | assert_eq!(h1, h2, "identical bytes hash to the same sample"); | |
| 1250 | 1424 | ||
| 1251 | 1425 | // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not | |
| 1252 | - | // write an unreachable `{hash}.aiff` orphan. | |
| 1253 | - | let blob_count = || { | |
| 1254 | - | std::fs::read_dir(store.root()) | |
| 1255 | - | .unwrap() | |
| 1256 | - | .filter_map(std::result::Result::ok) | |
| 1257 | - | .filter(|e| e.path().is_file()) | |
| 1258 | - | .count() | |
| 1259 | - | }; | |
| 1426 | + | // write an unreachable `{hash}.aiff` orphan. Counted recursively, because | |
| 1427 | + | // blobs live under a shard directory now; counting only the root's own files | |
| 1428 | + | // would read 0 here and pass this test vacuously for the wrong reason. | |
| 1429 | + | let blob_count = || count_blobs_recursively(store.root()); | |
| 1260 | 1430 | assert_eq!( | |
| 1261 | 1431 | blob_count(), | |
| 1262 | 1432 | 1, |
| @@ -1,0 +1,374 @@ | |||
| 1 | + | //! Background worker for the blob-directory layout migration. | |
| 2 | + | //! | |
| 3 | + | //! Mirrors the pattern in `cleanup.rs`: a dedicated thread with its own Database + | |
| 4 | + | //! SampleStore, communicating via channels, with the GUI thread polling events each | |
| 5 | + | //! frame. The work itself lives in `audiofiles_core::store::layout`; this is only | |
| 6 | + | //! the off-GUI-thread wrapper plus progress throttling. | |
| 7 | + | //! | |
| 8 | + | //! Why it must be off the GUI thread rather than a startup step: the sweep is one | |
| 9 | + | //! rename per blob on a filesystem whose metadata operations are the reason the | |
| 10 | + | //! migration exists at all. On the measured 289k-file library that is minutes at | |
| 11 | + | //! best, so doing it inline at vault open would present as a hang. | |
| 12 | + | ||
| 13 | + | use std::path::PathBuf; | |
| 14 | + | ||
| 15 | + | use tracing::{error, info, instrument, warn}; | |
| 16 | + | ||
| 17 | + | use audiofiles_core::config_key::ConfigKey; | |
| 18 | + | use audiofiles_core::db::Database; | |
| 19 | + | use audiofiles_core::store::SampleStore; | |
| 20 | + | use audiofiles_core::store::layout::{self, LayoutMigration}; | |
| 21 | + | use audiofiles_core::vfs_mirror::{MirrorConfig, sync_mirror}; | |
| 22 | + | use audiofiles_core::worker_runtime::{WorkerCtx, WorkerHandle, spawn_worker}; | |
| 23 | + | ||
| 24 | + | /// Emit at most one [`LayoutEvent::Progress`] per this many blobs handled. | |
| 25 | + | /// | |
| 26 | + | /// The event channel is bounded (4096) and applies backpressure, so an unthrottled | |
| 27 | + | /// per-blob emit over a 289k-blob sweep would have the worker waiting on a GUI that | |
| 28 | + | /// redraws at most 60 times a second, turning a progress bar into a brake. One | |
| 29 | + | /// event per 256 blobs is far finer than a human can perceive on a bar that takes | |
| 30 | + | /// minutes to fill. | |
| 31 | + | const PROGRESS_STRIDE: usize = 256; | |
| 32 | + | ||
| 33 | + | /// Command sent from the GUI thread to the layout worker. | |
| 34 | + | pub enum LayoutCommand { | |
| 35 | + | /// Start (or resume) relocating flat blobs into their shard directories. | |
| 36 | + | Migrate, | |
| 37 | + | /// Cancel the running migration (sets the worker's cancel flag synchronously). | |
| 38 | + | Cancel, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Event sent from the layout worker back to the GUI thread. | |
| 42 | + | pub enum LayoutEvent { | |
| 43 | + | /// Progress through the blobs enumerated at the start of this pass. | |
| 44 | + | Progress { completed: usize, total: usize }, | |
| 45 | + | /// The pass finished, was cancelled, or failed to start. | |
| 46 | + | Complete { | |
| 47 | + | /// Blobs relocated into a shard. | |
| 48 | + | moved: usize, | |
| 49 | + | /// Redundant flat blobs discarded because the shard already matched. | |
| 50 | + | deduped: usize, | |
| 51 | + | /// Blobs that could not be relocated. | |
| 52 | + | errors: usize, | |
| 53 | + | /// Stopped early on request. The vault stays resolvable and resumable. | |
| 54 | + | cancelled: bool, | |
| 55 | + | /// The vault is now fully sharded and recorded as such. | |
| 56 | + | completed: bool, | |
| 57 | + | /// The VFS mirror was rebuilt after a completed migration. | |
| 58 | + | mirror_rebuilt: bool, | |
| 59 | + | }, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl LayoutEvent { | |
| 63 | + | /// A `Complete` reporting that nothing ran, for the failure paths that must | |
| 64 | + | /// still emit a terminal event so the GUI's busy flag clears. | |
| 65 | + | fn failed() -> Self { | |
| 66 | + | LayoutEvent::Complete { | |
| 67 | + | moved: 0, | |
| 68 | + | deduped: 0, | |
| 69 | + | errors: 1, | |
| 70 | + | cancelled: false, | |
| 71 | + | completed: false, | |
| 72 | + | mirror_rebuilt: false, | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Handle for communicating with the background layout worker. | |
| 78 | + | pub struct LayoutHandle(WorkerHandle<LayoutCommand, LayoutEvent>); | |
| 79 | + | ||
| 80 | + | impl LayoutHandle { | |
| 81 | + | /// Poll for the next event without blocking. | |
| 82 | + | pub fn try_recv(&self) -> Option<LayoutEvent> { | |
| 83 | + | self.0.try_recv() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Send a command to the worker. Returns false if the worker is no longer | |
| 87 | + | /// alive, so callers don't treat a dropped command as accepted (and then wait | |
| 88 | + | /// forever for a terminal event that cannot arrive). | |
| 89 | + | pub fn send(&self, cmd: LayoutCommand) -> bool { | |
| 90 | + | if matches!(cmd, LayoutCommand::Cancel) { | |
| 91 | + | self.0.request_cancel(); | |
| 92 | + | } | |
| 93 | + | self.0.send(cmd) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Per-worker state: its own DB connection + store. | |
| 98 | + | struct LayoutWorker { | |
| 99 | + | db: Database, | |
| 100 | + | store: SampleStore, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Spawn the background layout-migration worker. | |
| 104 | + | #[instrument(skip_all)] | |
| 105 | + | pub fn spawn_layout_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Result<LayoutHandle> { | |
| 106 | + | let handle = spawn_worker( | |
| 107 | + | "layout-worker", | |
| 108 | + | move || -> Result<LayoutWorker, audiofiles_core::error::CoreError> { | |
| 109 | + | let db = Database::open(&db_path)?; | |
| 110 | + | let store = SampleStore::new(&store_root)?; | |
| 111 | + | Ok(LayoutWorker { db, store }) | |
| 112 | + | }, | |
| 113 | + | |e| { | |
| 114 | + | error!("Layout worker failed to open DB/store: {e}"); | |
| 115 | + | LayoutEvent::failed() | |
| 116 | + | }, | |
| 117 | + | |_state| LayoutEvent::failed(), | |
| 118 | + | layout_step, | |
| 119 | + | )?; | |
| 120 | + | Ok(LayoutHandle(handle)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[allow( | |
| 124 | + | clippy::needless_pass_by_value, | |
| 125 | + | reason = "signature dictated by worker_runtime::spawn_worker step-fn contract (FnMut(&mut State, Cmd, &WorkerCtx))" | |
| 126 | + | )] | |
| 127 | + | fn layout_step(worker: &mut LayoutWorker, cmd: LayoutCommand, ctx: &WorkerCtx<LayoutEvent>) { | |
| 128 | + | // Cancel: the flag was already set synchronously by the handle, so the queued | |
| 129 | + | // command itself is a no-op. | |
| 130 | + | if matches!(cmd, LayoutCommand::Cancel) { | |
| 131 | + | return; | |
| 132 | + | } | |
| 133 | + | // A stale cancel from a previous pass must not abort this one. | |
| 134 | + | ctx.reset_cancel(); | |
| 135 | + | ||
| 136 | + | let report = match layout::migrate_to_sharded( | |
| 137 | + | &worker.store, | |
| 138 | + | &worker.db, | |
| 139 | + | ctx.cancel_flag(), | |
| 140 | + | &mut |completed, total| { | |
| 141 | + | if completed % PROGRESS_STRIDE == 0 || completed == total { | |
| 142 | + | ctx.emit(LayoutEvent::Progress { completed, total }); | |
| 143 | + | } | |
| 144 | + | }, | |
| 145 | + | ) { | |
| 146 | + | Ok(report) => report, | |
| 147 | + | Err(e) => { | |
| 148 | + | error!("Layout migration failed: {e}"); | |
| 149 | + | ctx.emit(LayoutEvent::failed()); | |
| 150 | + | return; | |
| 151 | + | } | |
| 152 | + | }; | |
| 153 | + | ||
| 154 | + | // The mirror's symlinks point at resolved blob paths, so every link to a | |
| 155 | + | // relocated blob is now stale. Rebuilding is only worth doing once the sweep is | |
| 156 | + | // actually complete: mid-migration the resolver still finds the un-moved blobs, | |
| 157 | + | // so a partial rebuild would be work thrown away on the next pass. | |
| 158 | + | let mirror_rebuilt = report.completed && report.moved > 0 && rebuild_mirror(worker); | |
| 159 | + | ||
| 160 | + | let LayoutMigration { | |
| 161 | + | moved, | |
| 162 | + | deduped, | |
| 163 | + | errors, | |
| 164 | + | cancelled, | |
| 165 | + | completed, | |
| 166 | + | } = report; | |
| 167 | + | info!( | |
| 168 | + | moved, | |
| 169 | + | deduped, errors, cancelled, completed, mirror_rebuilt, "layout migration pass finished" | |
| 170 | + | ); | |
| 171 | + | ctx.emit(LayoutEvent::Complete { | |
| 172 | + | moved, | |
| 173 | + | deduped, | |
| 174 | + | errors, | |
| 175 | + | cancelled, | |
| 176 | + | completed, | |
| 177 | + | mirror_rebuilt, | |
| 178 | + | }); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Rebuild the VFS mirror if one is configured. Returns whether it ran and | |
| 182 | + | /// succeeded. | |
| 183 | + | /// | |
| 184 | + | /// Best-effort: the migration itself has already committed, and a mirror is a | |
| 185 | + | /// derived convenience tree, so a failure here is logged and reported rather than | |
| 186 | + | /// turned into a migration failure the user would be invited to retry. | |
| 187 | + | fn rebuild_mirror(worker: &LayoutWorker) -> bool { | |
| 188 | + | let enabled = worker | |
| 189 | + | .db | |
| 190 | + | .get_config(ConfigKey::MirrorEnabled) | |
| 191 | + | .ok() | |
| 192 | + | .flatten() | |
| 193 | + | .is_some_and(|v| v == "true" || v == "1"); | |
| 194 | + | if !enabled { | |
| 195 | + | return false; | |
| 196 | + | } | |
| 197 | + | let Some(mirror_root) = worker.db.get_config(ConfigKey::MirrorPath).ok().flatten() else { | |
| 198 | + | return false; | |
| 199 | + | }; | |
| 200 | + | let config = MirrorConfig { | |
| 201 | + | mirror_root: PathBuf::from(mirror_root), | |
| 202 | + | store_root: worker.store.root().to_path_buf(), | |
| 203 | + | }; | |
| 204 | + | match sync_mirror(&worker.db, &config) { | |
| 205 | + | Ok(stats) => { | |
| 206 | + | info!( | |
| 207 | + | links_created = stats.links_created, | |
| 208 | + | entries_removed = stats.entries_removed, | |
| 209 | + | "layout migration: mirror rebuilt" | |
| 210 | + | ); | |
| 211 | + | true | |
| 212 | + | } | |
| 213 | + | Err(e) => { | |
| 214 | + | warn!("layout migration: mirror rebuild failed: {e}"); | |
| 215 | + | false | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// Whether this vault has flat blobs left to relocate. | |
| 221 | + | /// | |
| 222 | + | /// Cheap (one `read_dir`) and safe to call at vault open to decide whether to | |
| 223 | + | /// dispatch [`LayoutCommand::Migrate`] at all. Checks the filesystem rather than | |
| 224 | + | /// trusting the recorded layout alone, so a vault whose sweep was interrupted | |
| 225 | + | /// before it could record completion still gets picked up. | |
| 226 | + | pub fn migration_pending(db: &Database, store_root: &std::path::Path) -> bool { | |
| 227 | + | if matches!(layout::recorded_layout(db), Ok(layout::BlobLayout::Sharded)) { | |
| 228 | + | return false; | |
| 229 | + | } | |
| 230 | + | layout::count_flat_blobs(store_root).unwrap_or(0) > 0 | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[cfg(test)] | |
| 234 | + | mod tests { | |
| 235 | + | use super::*; | |
| 236 | + | use audiofiles_core::store::legacy_flat_blob_path; | |
| 237 | + | ||
| 238 | + | const HASH: &str = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; | |
| 239 | + | ||
| 240 | + | fn poll_complete(handle: &LayoutHandle) -> LayoutEvent { | |
| 241 | + | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); | |
| 242 | + | while std::time::Instant::now() < deadline { | |
| 243 | + | while let Some(ev) = handle.try_recv() { | |
| 244 | + | if matches!(ev, LayoutEvent::Complete { .. }) { | |
| 245 | + | return ev; | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | std::thread::sleep(std::time::Duration::from_millis(5)); | |
| 249 | + | } | |
| 250 | + | panic!("layout worker did not report Complete within 10s"); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[test] | |
| 254 | + | fn spawn_and_drop_does_not_hang() { | |
| 255 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 256 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 257 | + | let store_root = dir.path().join("store"); | |
| 258 | + | std::fs::create_dir_all(&store_root).unwrap(); | |
| 259 | + | let _db = Database::open(&db_path).unwrap(); | |
| 260 | + | ||
| 261 | + | let handle = spawn_layout_worker(db_path, store_root).unwrap(); | |
| 262 | + | assert!(handle.try_recv().is_none()); | |
| 263 | + | drop(handle); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn worker_relocates_a_flat_blob() { | |
| 268 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 269 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 270 | + | let store_root = dir.path().join("store"); | |
| 271 | + | std::fs::create_dir_all(&store_root).unwrap(); | |
| 272 | + | let db = Database::open(&db_path).unwrap(); | |
| 273 | + | std::fs::write(legacy_flat_blob_path(&store_root, HASH, "wav"), b"bytes").unwrap(); | |
| 274 | + | ||
| 275 | + | assert!(migration_pending(&db, &store_root)); | |
| 276 | + | drop(db); | |
| 277 | + | ||
| 278 | + | let handle = spawn_layout_worker(db_path.clone(), store_root.clone()).unwrap(); | |
| 279 | + | assert!(handle.send(LayoutCommand::Migrate)); | |
| 280 | + | ||
| 281 | + | match poll_complete(&handle) { | |
| 282 | + | LayoutEvent::Complete { | |
| 283 | + | moved, | |
| 284 | + | errors, | |
| 285 | + | completed, | |
| 286 | + | cancelled, | |
| 287 | + | .. | |
| 288 | + | } => { | |
| 289 | + | assert_eq!(moved, 1); | |
| 290 | + | assert_eq!(errors, 0); | |
| 291 | + | assert!(completed); | |
| 292 | + | assert!(!cancelled); | |
| 293 | + | } | |
| 294 | + | LayoutEvent::Progress { .. } => unreachable!("filtered by poll_complete"), | |
| 295 | + | } | |
| 296 | + | drop(handle); | |
| 297 | + | ||
| 298 | + | assert!(store_root.join("aa").join(format!("{HASH}.wav")).is_file()); | |
| 299 | + | assert!(!legacy_flat_blob_path(&store_root, HASH, "wav").exists()); | |
| 300 | + | ||
| 301 | + | let db = Database::open(&db_path).unwrap(); | |
| 302 | + | assert!(!migration_pending(&db, &store_root)); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn empty_store_completes_without_work() { | |
| 307 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 308 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 309 | + | let store_root = dir.path().join("store"); | |
| 310 | + | std::fs::create_dir_all(&store_root).unwrap(); | |
| 311 | + | let db = Database::open(&db_path).unwrap(); | |
| 312 | + | // A fresh vault has no blobs, so nothing is pending even though the layout | |
| 313 | + | // has never been recorded. | |
| 314 | + | assert!(!migration_pending(&db, &store_root)); | |
| 315 | + | drop(db); | |
| 316 | + | ||
| 317 | + | let handle = spawn_layout_worker(db_path, store_root).unwrap(); | |
| 318 | + | assert!(handle.send(LayoutCommand::Migrate)); | |
| 319 | + | match poll_complete(&handle) { | |
| 320 | + | LayoutEvent::Complete { | |
| 321 | + | moved, | |
| 322 | + | errors, | |
| 323 | + | completed, | |
| 324 | + | mirror_rebuilt, | |
| 325 | + | .. | |
| 326 | + | } => { | |
| 327 | + | assert_eq!(moved, 0); | |
| 328 | + | assert_eq!(errors, 0); | |
| 329 | + | assert!(completed, "an empty root is trivially sharded"); | |
| 330 | + | assert!(!mirror_rebuilt, "nothing moved, so no rebuild"); | |
| 331 | + | } | |
| 332 | + | LayoutEvent::Progress { .. } => unreachable!("filtered by poll_complete"), | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn cancel_before_run_reports_cancelled_and_leaves_the_blob() { | |
| 338 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 339 | + | let db_path = dir.path().join("audiofiles.db"); | |
| 340 | + | let store_root = dir.path().join("store"); | |
| 341 | + | std::fs::create_dir_all(&store_root).unwrap(); | |
| 342 | + | let _db = Database::open(&db_path).unwrap(); | |
| 343 | + | std::fs::write(legacy_flat_blob_path(&store_root, HASH, "wav"), b"bytes").unwrap(); | |
| 344 | + | ||
| 345 | + | let handle = spawn_layout_worker(db_path, store_root.clone()).unwrap(); | |
| 346 | + | // Cancel sets the flag synchronously, so the Migrate queued behind it aborts | |
| 347 | + | // at its first check rather than running to completion. | |
| 348 | + | assert!(handle.send(LayoutCommand::Cancel)); | |
| 349 | + | assert!(handle.send(LayoutCommand::Migrate)); | |
| 350 | + | ||
| 351 | + | // Migrate resets the cancel flag at its start (so a stale cancel cannot | |
| 352 | + | // wedge every future pass), which means this run legitimately completes. | |
| 353 | + | // The point of the test is that the sequence terminates with a real event | |
| 354 | + | // and the data survives either way. | |
| 355 | + | match poll_complete(&handle) { | |
| 356 | + | LayoutEvent::Complete { errors, .. } => assert_eq!(errors, 0), | |
| 357 | + | LayoutEvent::Progress { .. } => unreachable!("filtered by poll_complete"), | |
| 358 | + | } | |
| 359 | + | drop(handle); | |
| 360 | + | ||
| 361 | + | let found = store_root.join("aa").join(format!("{HASH}.wav")).is_file() | |
| 362 | + | || legacy_flat_blob_path(&store_root, HASH, "wav").is_file(); | |
| 363 | + | assert!(found, "the blob must exist in one layout or the other"); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn layout_event_variants_constructible() { | |
| 368 | + | let _ = LayoutEvent::Progress { | |
| 369 | + | completed: 1, | |
| 370 | + | total: 2, | |
| 371 | + | }; | |
| 372 | + | let _ = LayoutEvent::failed(); | |
| 373 | + | } | |
| 374 | + | } |
| @@ -1,0 +1,407 @@ | |||
| 1 | + | //! Blob-directory layout: the flat-to-sharded migration and its bookkeeping. | |
| 2 | + | //! | |
| 3 | + | //! Vaults created before 2026-07-29 keep every blob in one flat directory. That | |
| 4 | + | //! collapses at scale: measured on a 289k-file library, import throughput fell | |
| 5 | + | //! about 90% between an empty vault and a 40,000-entry one, and a *dedup* pass | |
| 6 | + | //! doing strictly less work per file (no blob write, no fsync) still ran five | |
| 7 | + | //! times slower than a full write into an empty directory. The cost is kernel | |
| 8 | + | //! filesystem metadata work, so it cannot be optimised away on the read side; the | |
| 9 | + | //! directory has to stop being flat. Numbers and method in wiki `af-benchmarks`. | |
| 10 | + | //! | |
| 11 | + | //! [`migrate_to_sharded`] relocates a vault forward. It is resumable and | |
| 12 | + | //! idempotent, because on the filesystems that need it most a full sweep of a | |
| 13 | + | //! large library takes long enough to be interrupted: every step is a rename to a | |
| 14 | + | //! path derived from the blob's own content, so re-running continues rather than | |
| 15 | + | //! repeating, and an interrupted sweep leaves a vault that still resolves (reads | |
| 16 | + | //! check both layouts, see [`super::existing_blob_path`]). | |
| 17 | + | //! | |
| 18 | + | //! <!-- wiki: af-benchmarks --> | |
| 19 | + | ||
| 20 | + | use std::path::Path; | |
| 21 | + | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 22 | + | ||
| 23 | + | use tracing::{instrument, warn}; | |
| 24 | + | ||
| 25 | + | use super::{SampleStore, blob_shard, store_blob_path}; | |
| 26 | + | use crate::config_key::ConfigKey; | |
| 27 | + | use crate::db::Database; | |
| 28 | + | use crate::error::{Result, io_err}; | |
| 29 | + | ||
| 30 | + | /// On-disk layout of a vault's blob directory. | |
| 31 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 32 | + | pub enum BlobLayout { | |
| 33 | + | /// Every blob directly in the store root: `{root}/{hash}.{ext}`. Pre-2026-07-29. | |
| 34 | + | Flat, | |
| 35 | + | /// Blobs under a hash-prefix shard: `{root}/{ab}/{hash}.{ext}`. | |
| 36 | + | Sharded, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl BlobLayout { | |
| 40 | + | /// The `blob_layout` config value. | |
| 41 | + | #[must_use] | |
| 42 | + | pub const fn as_str(self) -> &'static str { | |
| 43 | + | match self { | |
| 44 | + | BlobLayout::Flat => "flat", | |
| 45 | + | BlobLayout::Sharded => "sharded", | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// The layout recorded for this vault. | |
| 51 | + | /// | |
| 52 | + | /// Absent or unrecognised reads as [`BlobLayout::Flat`], which is the fail-safe | |
| 53 | + | /// direction: it schedules a sweep that finds nothing on an already-sharded vault | |
| 54 | + | /// (one `read_dir`), whereas defaulting to `Sharded` would leave a genuinely flat | |
| 55 | + | /// vault permanently unmigrated and paying the cost this module exists to remove. | |
| 56 | + | /// | |
| 57 | + | /// Deliberately not a schema migration. The key's only job is to hold a default, | |
| 58 | + | /// and a migration whose entire body inserts one default row is more moving parts | |
| 59 | + | /// than reading `None` as `Flat`. | |
| 60 | + | pub fn recorded_layout(db: &Database) -> Result<BlobLayout> { | |
| 61 | + | Ok(match db.get_config(ConfigKey::BlobLayout)?.as_deref() { | |
| 62 | + | Some(v) if v == BlobLayout::Sharded.as_str() => BlobLayout::Sharded, | |
| 63 | + | _ => BlobLayout::Flat, | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Outcome of a [`migrate_to_sharded`] pass. | |
| 68 | + | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] | |
| 69 | + | pub struct LayoutMigration { | |
| 70 | + | /// Blobs relocated into a shard directory. | |
| 71 | + | pub moved: usize, | |
| 72 | + | /// Flat blobs discarded because the shard already held the same content. | |
| 73 | + | pub deduped: usize, | |
| 74 | + | /// Blobs that could not be relocated. Each is logged. | |
| 75 | + | pub errors: usize, | |
| 76 | + | /// The sweep stopped early because cancellation was requested. | |
| 77 | + | pub cancelled: bool, | |
| 78 | + | /// The root is clean and the vault is now recorded as [`BlobLayout::Sharded`]. | |
| 79 | + | pub completed: bool, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Split a store-root filename into `(hash, ext)`, or `None` if it is not a blob. | |
| 83 | + | /// | |
| 84 | + | /// Strict on purpose. The store root also holds shard directories, and can hold | |
| 85 | + | /// `{hash}.{ext}.{pid}.tmp` leftovers from an import that died between create and | |
| 86 | + | /// rename. Requiring exactly one dot and a 64-char lowercase-hex stem rejects both | |
| 87 | + | /// a temp file (two dots) and anything a user dropped in by hand, so the sweep | |
| 88 | + | /// only ever renames files it is certain are blobs. | |
| 89 | + | fn parse_blob_name(name: &str) -> Option<(&str, &str)> { | |
| 90 | + | let (hash, ext) = match name.split_once('.') { | |
| 91 | + | Some((hash, ext)) => (hash, ext), | |
| 92 | + | None => (name, ""), | |
| 93 | + | }; | |
| 94 | + | let is_hash = hash.len() == 64 | |
| 95 | + | && hash | |
| 96 | + | .bytes() | |
| 97 | + | .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase() && b.is_ascii_hexdigit()); | |
| 98 | + | if !is_hash || ext.contains('.') { | |
| 99 | + | return None; | |
| 100 | + | } | |
| 101 | + | Some((hash, ext)) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Every flat blob presently in the store root, as `(hash, ext)`. | |
| 105 | + | /// | |
| 106 | + | /// Non-recursive: shard directories are entries of the root and are skipped, so | |
| 107 | + | /// this counts exactly the work a sweep still has left. | |
| 108 | + | fn flat_blobs(store_root: &Path) -> Result<Vec<(String, String)>> { | |
| 109 | + | let mut out = Vec::new(); | |
| 110 | + | let entries = std::fs::read_dir(store_root).map_err(|e| io_err(store_root, e))?; | |
| 111 | + | for entry in entries.flatten() { | |
| 112 | + | // A directory here is a shard (or something a user made); never a blob. | |
| 113 | + | if !entry.file_type().is_ok_and(|t| t.is_file()) { | |
| 114 | + | continue; | |
| 115 | + | } | |
| 116 | + | let name = entry.file_name(); | |
| 117 | + | let Some(name) = name.to_str() else { continue }; | |
| 118 | + | if let Some((hash, ext)) = parse_blob_name(name) { | |
| 119 | + | out.push((hash.to_string(), ext.to_string())); | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | Ok(out) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// How many flat blobs are still in the store root. | |
| 126 | + | /// | |
| 127 | + | /// Cheap enough to call before deciding whether to start a sweep: one `read_dir`. | |
| 128 | + | pub fn count_flat_blobs(store_root: &Path) -> Result<usize> { | |
| 129 | + | Ok(flat_blobs(store_root)?.len()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Relocate every flat blob in the store root into its hash-prefix shard. | |
| 133 | + | /// | |
| 134 | + | /// Resumable, idempotent and cancellable. `on_progress` is called with | |
| 135 | + | /// `(done, total)` as each blob is handled. On a clean pass (nothing cancelled, no | |
| 136 | + | /// errors, root verified empty of blobs afterwards) the vault is recorded as | |
| 137 | + | /// [`BlobLayout::Sharded`] and [`LayoutMigration::completed`] is set; otherwise the | |
| 138 | + | /// recorded layout is left alone so the next run picks up the remainder. | |
| 139 | + | /// | |
| 140 | + | /// No per-blob fsync. The relocation is a pure rename of content-addressed data, so | |
| 141 | + | /// an untimely crash can only lose the *dirent update*, never bytes: the blob is | |
| 142 | + | /// still at one of the two paths a read checks, and re-running the sweep finishes | |
| 143 | + | /// the job. Paying a metadata flush per file would reproduce the per-file fsync cost | |
| 144 | + | /// that the same measurement found was worth about 27% of import time. | |
| 145 | + | #[instrument(skip_all)] | |
| 146 | + | pub fn migrate_to_sharded( | |
| 147 | + | store: &SampleStore, | |
| 148 | + | db: &Database, | |
| 149 | + | cancel: &AtomicBool, | |
| 150 | + | on_progress: &mut dyn FnMut(usize, usize), | |
| 151 | + | ) -> Result<LayoutMigration> { | |
| 152 | + | let root = store.root(); | |
| 153 | + | let pending = flat_blobs(root)?; | |
| 154 | + | let total = pending.len(); | |
| 155 | + | let mut report = LayoutMigration::default(); | |
| 156 | + | ||
| 157 | + | for (done, (hash, ext)) in pending.iter().enumerate() { | |
| 158 | + | if cancel.load(Ordering::Acquire) { | |
| 159 | + | report.cancelled = true; | |
| 160 | + | break; | |
| 161 | + | } | |
| 162 | + | let src = super::legacy_flat_blob_path(root, hash, ext); | |
| 163 | + | let dest = store_blob_path(root, hash, ext); | |
| 164 | + | let shard_dir = root.join(blob_shard(hash)); | |
| 165 | + | ||
| 166 | + | if let Err(e) = std::fs::create_dir_all(&shard_dir) { | |
| 167 | + | warn!(shard = %shard_dir.display(), "layout: shard create failed: {e}"); | |
| 168 | + | report.errors += 1; | |
| 169 | + | continue; | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // A blob already at the destination is the same content by construction, | |
| 173 | + | // so the flat copy is redundant and should go. Size is still checked | |
| 174 | + | // first: if they differ, one of them is a truncated blob from a | |
| 175 | + | // pre-atomic-rename crash, and silently deleting either could destroy the | |
| 176 | + | // intact one. That case is left alone for a human and counted as an error. | |
| 177 | + | match (std::fs::metadata(&dest), std::fs::metadata(&src)) { | |
| 178 | + | (Ok(d), Ok(s)) if d.len() == s.len() => match std::fs::remove_file(&src) { | |
| 179 | + | Ok(()) => report.deduped += 1, | |
| 180 | + | Err(e) => { | |
| 181 | + | warn!(path = %src.display(), "layout: redundant flat blob unlink failed: {e}"); | |
| 182 | + | report.errors += 1; | |
| 183 | + | } | |
| 184 | + | }, | |
| 185 | + | (Ok(d), Ok(s)) => { | |
| 186 | + | warn!( | |
| 187 | + | path = %src.display(), | |
| 188 | + | flat_len = s.len(), | |
| 189 | + | sharded_len = d.len(), | |
| 190 | + | "layout: size mismatch between flat and sharded blob, leaving both for inspection" | |
| 191 | + | ); | |
| 192 | + | report.errors += 1; | |
| 193 | + | } | |
| 194 | + | _ => match std::fs::rename(&src, &dest) { | |
| 195 | + | Ok(()) => report.moved += 1, | |
| 196 | + | Err(e) => { | |
| 197 | + | warn!(path = %src.display(), "layout: rename into shard failed: {e}"); | |
| 198 | + | report.errors += 1; | |
| 199 | + | } | |
| 200 | + | }, | |
| 201 | + | } | |
| 202 | + | on_progress(done + 1, total); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // One directory fsync for the whole sweep, so the renames are durable without | |
| 206 | + | // paying a metadata flush per blob. Best-effort: not every filesystem supports | |
| 207 | + | // it, and failure here only weakens durability, never correctness. | |
| 208 | + | if let Ok(d) = std::fs::File::open(root) { | |
| 209 | + | let _ = d.sync_all(); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Only claim completion against a re-scan. A blob could have been written | |
| 213 | + | // flat by another process between the enumeration and here, and recording | |
| 214 | + | // `Sharded` over one would strand it: reads would still find it via the | |
| 215 | + | // fallback, but nothing would ever move it. | |
| 216 | + | if !report.cancelled && report.errors == 0 && count_flat_blobs(root)? == 0 { | |
| 217 | + | db.set_config(ConfigKey::BlobLayout, BlobLayout::Sharded.as_str())?; | |
| 218 | + | report.completed = true; | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | Ok(report) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[cfg(test)] | |
| 225 | + | mod tests { | |
| 226 | + | use super::*; | |
| 227 | + | use crate::SampleHash; | |
| 228 | + | ||
| 229 | + | const HASH_A: &str = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; | |
| 230 | + | const HASH_B: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; | |
| 231 | + | ||
| 232 | + | fn store_with_flat_blob(root: &Path, hash: &str, ext: &str, bytes: &[u8]) { | |
| 233 | + | std::fs::write(super::super::legacy_flat_blob_path(root, hash, ext), bytes).unwrap(); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn parse_blob_name_accepts_a_blob_with_and_without_extension() { | |
| 238 | + | assert_eq!(parse_blob_name(HASH_A), Some((HASH_A, ""))); | |
| 239 | + | assert_eq!( | |
| 240 | + | parse_blob_name(&format!("{HASH_A}.wav")), | |
| 241 | + | Some((HASH_A, "wav")) | |
| 242 | + | ); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn parse_blob_name_rejects_temp_files_and_non_blobs() { | |
| 247 | + | // The exact shape import leaves behind when it dies before the rename. | |
| 248 | + | assert_eq!(parse_blob_name(&format!("{HASH_A}.wav.12345.tmp")), None); | |
| 249 | + | assert_eq!(parse_blob_name("audiofiles.db"), None); | |
| 250 | + | assert_eq!(parse_blob_name("notes.txt"), None); | |
| 251 | + | // Uppercase hex is not the spelling the store writes. | |
| 252 | + | assert_eq!(parse_blob_name(&HASH_A.to_uppercase()), None); | |
| 253 | + | // Right charset, wrong length. | |
| 254 | + | assert_eq!(parse_blob_name("aabbcc.wav"), None); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[test] | |
| 258 | + | fn sweep_relocates_flat_blobs_and_records_the_layout() { | |
| 259 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 260 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 261 | + | let root = dir.path().join("samples"); | |
| 262 | + | let store = SampleStore::new(&root).unwrap(); | |
| 263 | + | ||
| 264 | + | store_with_flat_blob(&root, HASH_A, "wav", b"aaaa"); | |
| 265 | + | store_with_flat_blob(&root, HASH_B, "flac", b"bbbbbb"); | |
| 266 | + | ||
| 267 | + | assert_eq!(recorded_layout(&db).unwrap(), BlobLayout::Flat); | |
| 268 | + | assert_eq!(count_flat_blobs(&root).unwrap(), 2); | |
| 269 | + | ||
| 270 | + | let cancel = AtomicBool::new(false); | |
| 271 | + | let mut seen = Vec::new(); | |
| 272 | + | let report = | |
| 273 | + | migrate_to_sharded(&store, &db, &cancel, &mut |d, t| seen.push((d, t))).unwrap(); | |
| 274 | + | ||
| 275 | + | assert_eq!(report.moved, 2); | |
| 276 | + | assert_eq!(report.deduped, 0); | |
| 277 | + | assert_eq!(report.errors, 0); | |
| 278 | + | assert!(report.completed); | |
| 279 | + | assert_eq!(seen, vec![(1, 2), (2, 2)]); | |
| 280 | + | ||
| 281 | + | // Bytes are where the sharded layout says, and gone from the flat one. | |
| 282 | + | assert!(store_blob_path(&root, HASH_A, "wav").exists()); | |
| 283 | + | assert!(root.join("aa").is_dir()); | |
| 284 | + | assert!(!super::super::legacy_flat_blob_path(&root, HASH_A, "wav").exists()); | |
| 285 | + | assert_eq!(count_flat_blobs(&root).unwrap(), 0); | |
| 286 | + | assert_eq!(recorded_layout(&db).unwrap(), BlobLayout::Sharded); | |
| 287 | + | ||
| 288 | + | // And the store resolves them. | |
| 289 | + | let resolved = store | |
| 290 | + | .sample_path(&SampleHash::from_trusted(HASH_A.to_string()), "wav") | |
| 291 | + | .unwrap(); | |
| 292 | + | assert_eq!(resolved, store_blob_path(&root, HASH_A, "wav")); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[test] | |
| 296 | + | fn sweep_is_idempotent() { | |
| 297 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 298 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 299 | + | let root = dir.path().join("samples"); | |
| 300 | + | let store = SampleStore::new(&root).unwrap(); | |
| 301 | + | store_with_flat_blob(&root, HASH_A, "wav", b"aaaa"); | |
| 302 | + | ||
| 303 | + | let cancel = AtomicBool::new(false); | |
| 304 | + | let first = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 305 | + | let second = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 306 | + | ||
| 307 | + | assert_eq!(first.moved, 1); | |
| 308 | + | assert_eq!(second.moved, 0); | |
| 309 | + | assert!( | |
| 310 | + | second.completed, | |
| 311 | + | "a clean already-sharded vault stays sharded" | |
| 312 | + | ); | |
| 313 | + | assert!(store_blob_path(&root, HASH_A, "wav").exists()); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | #[test] | |
| 317 | + | fn sweep_discards_a_redundant_flat_blob_matching_its_shard() { | |
| 318 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 319 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 320 | + | let root = dir.path().join("samples"); | |
| 321 | + | let store = SampleStore::new(&root).unwrap(); | |
| 322 | + | ||
| 323 | + | // Same content in both layouts: an interrupted sweep plus a repair write. | |
| 324 | + | store_with_flat_blob(&root, HASH_A, "wav", b"aaaa"); | |
| 325 | + | std::fs::create_dir_all(root.join("aa")).unwrap(); | |
| 326 | + | std::fs::write(store_blob_path(&root, HASH_A, "wav"), b"aaaa").unwrap(); | |
| 327 | + | ||
| 328 | + | let cancel = AtomicBool::new(false); | |
| 329 | + | let report = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 330 | + | ||
| 331 | + | assert_eq!(report.deduped, 1); | |
| 332 | + | assert_eq!(report.moved, 0); | |
| 333 | + | assert_eq!(report.errors, 0); | |
| 334 | + | assert!(report.completed); | |
| 335 | + | assert!(!super::super::legacy_flat_blob_path(&root, HASH_A, "wav").exists()); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn sweep_leaves_a_size_mismatch_alone_and_does_not_complete() { | |
| 340 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 341 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 342 | + | let root = dir.path().join("samples"); | |
| 343 | + | let store = SampleStore::new(&root).unwrap(); | |
| 344 | + | ||
| 345 | + | // One of these is a truncated blob from a pre-atomic-rename crash. Deleting | |
| 346 | + | // either could destroy the intact copy, so both must survive the sweep. | |
| 347 | + | store_with_flat_blob(&root, HASH_A, "wav", b"aaaaaaaa"); | |
| 348 | + | std::fs::create_dir_all(root.join("aa")).unwrap(); | |
| 349 | + | std::fs::write(store_blob_path(&root, HASH_A, "wav"), b"aa").unwrap(); | |
| 350 | + | ||
| 351 | + | let cancel = AtomicBool::new(false); | |
| 352 | + | let report = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 353 | + | ||
| 354 | + | assert_eq!(report.errors, 1); | |
| 355 | + | assert_eq!(report.moved, 0); | |
| 356 | + | assert_eq!(report.deduped, 0); | |
| 357 | + | assert!( | |
| 358 | + | !report.completed, | |
| 359 | + | "an unresolved blob must not record Sharded" | |
| 360 | + | ); | |
| 361 | + | assert!(super::super::legacy_flat_blob_path(&root, HASH_A, "wav").exists()); | |
| 362 | + | assert!(store_blob_path(&root, HASH_A, "wav").exists()); | |
| 363 | + | assert_eq!(recorded_layout(&db).unwrap(), BlobLayout::Flat); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn cancelled_sweep_keeps_the_flat_layout_recorded() { | |
| 368 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 369 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 370 | + | let root = dir.path().join("samples"); | |
| 371 | + | let store = SampleStore::new(&root).unwrap(); | |
| 372 | + | store_with_flat_blob(&root, HASH_A, "wav", b"aaaa"); | |
| 373 | + | ||
| 374 | + | let cancel = AtomicBool::new(true); | |
| 375 | + | let report = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 376 | + | ||
| 377 | + | assert!(report.cancelled); | |
| 378 | + | assert_eq!(report.moved, 0); | |
| 379 | + | assert!(!report.completed); | |
| 380 | + | assert_eq!(recorded_layout(&db).unwrap(), BlobLayout::Flat); | |
| 381 | + | // The blob is untouched and still resolvable, so a cancel is not data loss. | |
| 382 | + | assert!(super::super::legacy_flat_blob_path(&root, HASH_A, "wav").exists()); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[test] | |
| 386 | + | fn sweep_ignores_temp_leftovers_and_shard_directories() { | |
| 387 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 388 | + | let db = Database::open(dir.path().join("audiofiles.db")).unwrap(); | |
| 389 | + | let root = dir.path().join("samples"); | |
| 390 | + | let store = SampleStore::new(&root).unwrap(); | |
| 391 | + | ||
| 392 | + | let tmp = root.join(format!("{HASH_A}.wav.999.tmp")); | |
| 393 | + | std::fs::write(&tmp, b"partial").unwrap(); | |
| 394 | + | store_with_flat_blob(&root, HASH_B, "wav", b"bbbb"); | |
| 395 | + | ||
| 396 | + | let cancel = AtomicBool::new(false); | |
| 397 | + | let report = migrate_to_sharded(&store, &db, &cancel, &mut |_, _| {}).unwrap(); | |
| 398 | + | ||
| 399 | + | assert_eq!(report.moved, 1, "only the real blob moves"); | |
| 400 | + | assert_eq!(report.errors, 0); | |
| 401 | + | assert!(tmp.exists(), "a temp leftover is not the sweep's business"); | |
| 402 | + | assert!( | |
| 403 | + | report.completed, | |
| 404 | + | "a temp file is not a flat blob, so it must not block completion" | |
| 405 | + | ); | |
| 406 | + | } | |
| 407 | + | } |