Skip to main content

max / audiofiles

Auto-start the blob layout migration behind a cancellable strip Wires the sweep from the previous commit to a trigger and a progress UI. DirectBackend::new asks migration_pending (one read_dir, and it checks the filesystem rather than trusting the recorded layout, so an interrupted sweep resumes) and spawns the worker when flat blobs are present. No prompt: the migration relocates content-addressed data with nothing for the user to decide, and it only gets slower the longer a library grows, so asking would be a question whose answer is always yes. Progress surfaces as a thin strip above the footer, shown only while a migration runs, with a Pause button. Not a modal or an ImportMode screen like the cleanup worker uses: the sweep starts unbidden at vault open and the library stays fully usable while it runs, since reads resolve both layouts, so seizing the window would be the wrong trade. Pausing is honest rather than destructive, because the sweep records nothing until a pass verifies the root is clean, so stopping defers the remainder to the next open. The strip's total is the blob count for the current pass, not the whole library, which keeps a resumed pass honest about the work in front of it. Progress events are throttled to one per 256 blobs. The event channel is bounded and applies backpressure, so an unthrottled per-blob emit over a 289k-blob sweep would have the worker waiting on the GUI, turning a progress bar into a brake. Terminal events report through the status line and distinguish the three outcomes that matter: completed, paused-and-will-resume, and finished with problems. handle_layout_event never ends the poll batch, since relocating a blob changes no row and no query result.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 21:06 UTC
Signed with PGP, not checked
Commit: 1258f9e3b57f0d898fed304ba0d3e9db612f6cf8
Parent: 9510d13
9 files changed, +355 insertions, -3 deletions
@@ -5,7 +5,7 @@
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 7 detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel,
8 - import_screens, instrument_panel, overlays, sidebar, theme, toolbar,
8 + import_screens, instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -200,6 +200,15 @@
200 200 footer::draw_footer(ui, &ctx, state);
201 201 });
202 202
203 + // Blob-layout migration strip. Declared after the footer so it stacks *above*
204 + // it, and only while a migration is actually running, so the app has no extra
205 + // furniture in the normal case.
206 + if state.layout_migration.is_some() {
207 + egui::Panel::bottom("layout_migration_strip").show(ui, |ui| {
208 + layout_strip::draw_layout_strip(ui, state);
209 + });
210 + }
211 +
203 212 // Floating MIDI/instrument window
204 213 if state.preview.show_midi_window {
205 214 instrument_panel::draw_midi_window(&ctx, state);
@@ -115,6 +115,22 @@
115 115 errors: usize,
116 116 },
117 117
118 + // Blob-layout migration events (flat store root -> hash-prefix shards)
119 + LayoutProgress {
120 + completed: usize,
121 + total: usize,
122 + },
123 + LayoutComplete {
124 + /// Blobs relocated into a shard directory.
125 + moved: usize,
126 + /// Blobs that could not be relocated.
127 + errors: usize,
128 + /// Stopped early on request. The vault stays resolvable and resumable.
129 + cancelled: bool,
130 + /// The vault is fully sharded now.
131 + completed: bool,
132 + },
133 +
118 134 // Loose-files maintenance events
119 135 LooseFilesIntegrity {
120 136 missing: usize,
@@ -910,6 +926,13 @@
910 926 /// Cancel a running cleanup.
911 927 fn cancel_cleanup(&self) -> BackendResult<()>;
912 928
929 + /// Cancel a running blob-layout migration.
930 + ///
931 + /// There is no matching `start_`: the migration auto-starts at vault open when
932 + /// flat blobs are present, and the sweep is resumable, so cancelling only
933 + /// defers the remainder to the next open rather than abandoning it.
934 + fn cancel_layout_migration(&self) -> BackendResult<()>;
935 +
913 936 /// Record an edit in the edit_history table.
914 937 fn record_edit_history(
915 938 &self,
@@ -8,7 +8,7 @@
8 8
9 9 use super::{
10 10 AnalysisConfig, Arc, BrowserState, FolderTagEntry, ImportMode, ImportStrategy,
11 - ImportStrategyDesc, ImportedFolder,
11 + ImportStrategyDesc, ImportedFolder, LayoutMigrationStatus,
12 12 };
13 13 use crate::backend::BackendEvent;
14 14
@@ -289,6 +289,9 @@
289 289 BackendEvent::CleanupProgress { .. } | BackendEvent::CleanupComplete { .. } => {
290 290 self.handle_cleanup_event(event)
291 291 }
292 + BackendEvent::LayoutProgress { .. } | BackendEvent::LayoutComplete { .. } => {
293 + self.handle_layout_event(&event)
294 + }
292 295 BackendEvent::LooseFilesIntegrity { .. }
293 296 | BackendEvent::LooseFilesRelocated { .. }
294 297 | BackendEvent::LooseFilesPurged { .. }
@@ -500,6 +503,42 @@
500 503 false
501 504 }
502 505
506 + /// Blob-layout migration events: progress and completion.
507 + ///
508 + /// Never returns `true`. The terminal handlers end the poll batch so they can
509 + /// refresh library state, but relocating a blob changes no row and no query
510 + /// result, so there is nothing to refresh and no reason to cut the batch short.
511 + /// Takes a reference, unlike its sibling handlers: every field of these two
512 + /// events is `Copy`, so there is nothing to consume.
513 + pub(super) fn handle_layout_event(&mut self, event: &BackendEvent) -> bool {
514 + match *event {
515 + BackendEvent::LayoutProgress { completed, total } => {
516 + self.layout_migration = Some(LayoutMigrationStatus { completed, total });
517 + }
518 + BackendEvent::LayoutComplete {
519 + moved,
520 + errors,
521 + cancelled,
522 + completed,
523 + } => {
524 + self.layout_migration = None;
525 + if errors > 0 {
526 + self.status = format!(
527 + "Storage migration finished with {errors} problem(s), {moved} file(s) moved (see the log)"
528 + );
529 + } else if cancelled {
530 + self.status = format!(
531 + "Storage migration paused after {moved} file(s), resumes on next open"
532 + );
533 + } else if completed && moved > 0 {
534 + self.status = format!("Storage migration complete ({moved} file(s) moved)");
535 + }
536 + }
537 + _ => unreachable!("handle_layout_event: unrelated event"),
538 + }
539 + false
540 + }
541 +
503 542 /// Loose-files maintenance events: integrity, relocate, purge, failures.
504 543 fn handle_loose_files_event(&mut self, event: BackendEvent) -> bool {
505 544 match event {
@@ -137,6 +137,29 @@
137 137 }
138 138 }
139 139
140 + /// Progress of the background blob-layout migration.
141 + ///
142 + /// `total` is the blob count enumerated at the start of the current pass, not the
143 + /// whole library: the sweep is resumable, so a resumed pass counts only what is
144 + /// left. That makes the bar honest about the work in front of it rather than
145 + /// showing a fraction of a total the user cannot see.
146 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
147 + pub struct LayoutMigrationStatus {
148 + pub completed: usize,
149 + pub total: usize,
150 + }
151 +
152 + impl LayoutMigrationStatus {
153 + /// Completion as 0.0..=1.0, guarding the empty-pass division.
154 + #[must_use]
155 + pub fn fraction(self) -> f32 {
156 + if self.total == 0 {
157 + return 0.0;
158 + }
159 + (self.completed as f32 / self.total as f32).clamp(0.0, 1.0)
160 + }
161 + }
162 +
140 163 /// GUI-thread-only state, passed as egui user_state T.
141 164 pub struct BrowserState {
142 165 pub data_dir: PathBuf,
@@ -148,6 +171,14 @@
148 171 /// applied on the GUI thread via `draw_browser`'s poll.
149 172 pub dialogs: crate::ui::dialog::DialogManager,
150 173 pub status: String,
174 + /// Progress of the background blob-layout migration, or `None` when none is
175 + /// running. Drives a cancellable strip above the footer.
176 + ///
177 + /// Deliberately not an `ImportMode` like the cleanup screen: the migration
178 + /// auto-starts at vault open, so a modal or full-screen mode would seize the
179 + /// app for something the user did not ask for and does not need to watch. The
180 + /// library stays fully usable while it runs, because reads resolve both layouts.
181 + pub layout_migration: Option<LayoutMigrationStatus>,
151 182 /// When the current `status` message was posted. Drives the footer's
152 183 /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means
153 184 /// the status was set without going through `post_status` (legacy direct
@@ -426,6 +457,7 @@
426 457 },
427 458 dialogs: crate::ui::dialog::DialogManager::default(),
428 459 status: String::new(),
460 + layout_migration: None,
429 461 status_set_at: None,
430 462 detail: DetailUiState {
431 463 detail_visible,
@@ -3221,3 +3221,121 @@
3221 3221 );
3222 3222 }
3223 3223 }
3224 +
3225 + /// Blob-layout migration strip: the state the auto-started sweep drives.
3226 + mod layout_migration_strip {
3227 + use super::*;
3228 + use crate::backend::BackendEvent;
3229 +
3230 + #[test]
3231 + fn layout_migration_fraction_guards_an_empty_pass() {
3232 + // A pass that enumerated nothing must not divide by zero, and a resumed
3233 + // pass must not report over 100% if a count ever disagrees.
3234 + assert!(
3235 + (LayoutMigrationStatus {
3236 + completed: 0,
3237 + total: 0
3238 + }
3239 + .fraction()
3240 + - 0.0)
3241 + .abs()
3242 + < f32::EPSILON
3243 + );
3244 + assert!(
3245 + (LayoutMigrationStatus {
3246 + completed: 5,
3247 + total: 10
3248 + }
3249 + .fraction()
3250 + - 0.5)
3251 + .abs()
3252 + < f32::EPSILON
3253 + );
3254 + assert!(
3255 + (LayoutMigrationStatus {
3256 + completed: 99,
3257 + total: 10
3258 + }
3259 + .fraction()
3260 + - 1.0)
3261 + .abs()
3262 + < f32::EPSILON
3263 + );
3264 + }
3265 +
3266 + #[test]
3267 + fn layout_progress_shows_the_strip_and_completion_clears_it() {
3268 + let (mut state, _dir) = make_state();
3269 + assert!(
3270 + state.layout_migration.is_none(),
3271 + "no strip when nothing runs"
3272 + );
3273 +
3274 + let stop = state.handle_layout_event(&BackendEvent::LayoutProgress {
3275 + completed: 256,
3276 + total: 1024,
3277 + });
3278 + assert!(
3279 + !stop,
3280 + "a relocation changes no row, so it must not cut the poll batch"
3281 + );
3282 + assert_eq!(
3283 + state.layout_migration,
3284 + Some(LayoutMigrationStatus {
3285 + completed: 256,
3286 + total: 1024
3287 + })
3288 + );
3289 +
3290 + state.handle_layout_event(&BackendEvent::LayoutComplete {
3291 + moved: 1024,
3292 + errors: 0,
3293 + cancelled: false,
3294 + completed: true,
3295 + });
3296 + assert!(
3297 + state.layout_migration.is_none(),
3298 + "strip clears on completion"
3299 + );
3300 + assert!(
3301 + state.status.contains("1024"),
3302 + "the outcome is reported, got: {}",
3303 + state.status
3304 + );
3305 + }
3306 +
3307 + #[test]
3308 + fn cancelled_layout_migration_says_it_will_resume() {
3309 + let (mut state, _dir) = make_state();
3310 + state.handle_layout_event(&BackendEvent::LayoutComplete {
3311 + moved: 40,
3312 + errors: 0,
3313 + cancelled: true,
3314 + completed: false,
3315 + });
3316 + assert!(state.layout_migration.is_none());
3317 + // A pause is not a failure and must not read as one: the remaining work is
3318 + // picked up on the next open, and the message has to say so.
3319 + assert!(
3320 + state.status.contains("resumes"),
3321 + "a paused migration must promise resumption, got: {}",
3322 + state.status
3323 + );
3324 + }
3325 +
3326 + #[test]
3327 + fn layout_migration_errors_are_reported_not_swallowed() {
3328 + let (mut state, _dir) = make_state();
3329 + state.handle_layout_event(&BackendEvent::LayoutComplete {
3330 + moved: 3,
3331 + errors: 2,
3332 + cancelled: false,
3333 + completed: false,
3334 + });
3335 + assert!(
3336 + state.status.contains('2'),
3337 + "the error count must reach the user, got: {}",
3338 + state.status
3339 + );
3340 + }
3341 + }
@@ -13,6 +13,7 @@
13 13 pub mod forge_panel;
14 14 pub mod import_screens;
15 15 pub mod instrument_panel;
16 + pub mod layout_strip;
16 17 pub mod overlays;
17 18 pub mod settings_panel;
18 19 pub mod sidebar;
@@ -66,6 +66,7 @@
66 66 analysis_worker: Mutex<Option<WorkerHandle>>,
67 67 export_worker: Mutex<Option<ExportHandle>>,
68 68 cleanup_worker: Mutex<Option<CleanupHandle>>,
69 + layout_worker: Mutex<Option<crate::layout_migration::LayoutHandle>>,
69 70 loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>,
70 71 edit_worker: Mutex<Option<EditWorkerHandle>>,
71 72 forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>,
@@ -97,7 +98,12 @@
97 98 Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"),
98 99 Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"),
99 100 }
100 - Self {
101 + // Does this vault still hold blobs in the pre-2026-07-29 flat layout? One
102 + // `read_dir` (see `migration_pending`), so it is cheap enough to ask on
103 + // every open, and it reads the filesystem rather than trusting the recorded
104 + // layout so an interrupted sweep is picked back up.
105 + let layout_pending = crate::layout_migration::migration_pending(&db, store.root());
106 + let backend = Self {
101 107 db: Mutex::new(db),
102 108 store,
103 109 data_dir,
@@ -105,6 +111,7 @@
105 111 analysis_worker: Mutex::new(None),
106 112 export_worker: Mutex::new(None),
107 113 cleanup_worker: Mutex::new(None),
114 + layout_worker: Mutex::new(None),
108 115 loose_files_worker: Mutex::new(None),
109 116 edit_worker: Mutex::new(None),
110 117 forge_worker: Mutex::new(None),
@@ -123,7 +130,38 @@
123 130 );
124 131 audiofiles_rhai::registry::PluginRegistry::new()
125 132 }),
133 + };
134 + // Auto-start, no prompt. The migration is pure relocation of
135 + // content-addressed data with nothing for the user to decide, and it only
136 + // gets slower the longer a library grows, so asking would be a question
137 + // whose right answer is always yes. It runs on its own thread and reports
138 + // through a cancellable progress strip, so it stays visible and
139 + // interruptible without blocking the app.
140 + if layout_pending {
141 + match backend.spawn_layout_migration() {
142 + Ok(()) => tracing::info!("blob layout migration started"),
143 + Err(e) => tracing::warn!("blob layout migration failed to start: {e}"),
144 + }
126 145 }
146 + backend
147 + }
148 +
149 + /// Spawn (or restart) the layout-migration worker and set it going.
150 + ///
151 + /// Replaces any existing worker, cancelling it first, so two sweeps can never
152 + /// race over the same blobs.
153 + fn spawn_layout_migration(&self) -> BackendResult<()> {
154 + use crate::layout_migration::LayoutCommand;
155 + if let Some(worker) = self.layout_worker.lock().take() {
156 + worker.send(LayoutCommand::Cancel);
157 + }
158 + let db_path = self.data_dir.join("audiofiles.db");
159 + let handle =
160 + crate::layout_migration::spawn_layout_worker(db_path, self.store.root().to_path_buf())
161 + .map_err(|e| BackendError::Other(format!("failed to spawn layout worker: {e}")))?;
162 + handle.send(LayoutCommand::Migrate);
163 + *self.layout_worker.lock() = Some(handle);
164 + Ok(())
127 165 }
128 166
129 167 /// Access the store (needed for preview decode path in BrowserState).
@@ -204,6 +204,13 @@
204 204 Ok(())
205 205 }
206 206
207 + fn cancel_layout_migration(&self) -> BackendResult<()> {
208 + if let Some(worker) = self.layout_worker.lock().take() {
209 + worker.send(crate::layout_migration::LayoutCommand::Cancel);
210 + }
211 + Ok(())
212 + }
213 +
207 214 fn record_edit_history(
208 215 &self,
209 216 source_hash: &str,
@@ -387,6 +394,29 @@
387 394 }
388 395 }
389 396
397 + if let Some(ref worker) = *self.layout_worker.lock() {
398 + use crate::layout_migration::LayoutEvent as Lay;
399 + while let Some(event) = worker.try_recv() {
400 + events.push(match event {
401 + Lay::Progress { completed, total } => {
402 + BackendEvent::LayoutProgress { completed, total }
403 + }
404 + Lay::Complete {
405 + moved,
406 + errors,
407 + cancelled,
408 + completed,
409 + ..
410 + } => BackendEvent::LayoutComplete {
411 + moved,
412 + errors,
413 + cancelled,
414 + completed,
415 + },
416 + });
417 + }
418 + }
419 +
390 420 // Poll loose-files worker
391 421 if let Some(ref worker) = *self.loose_files_worker.lock() {
392 422 use crate::loose_files_worker::LooseFilesEvent as Lfe;
@@ -1,0 +1,62 @@
1 + //! Blob-layout migration strip: a thin, cancellable progress row above the footer.
2 + //!
3 + //! Shown only while the background migration is relocating blobs from the legacy
4 + //! flat store root into hash-prefix shards. Deliberately a strip and not a modal or
5 + //! a full-screen mode: the migration auto-starts at vault open rather than being
6 + //! asked for, and the library stays completely usable while it runs, because reads
7 + //! resolve both layouts. Seizing the window for it would be the wrong trade.
8 + //!
9 + //! Cancelling is honest here. The sweep is resumable and records nothing until a
10 + //! pass verifies the root is clean, so stopping defers the remainder to the next
11 + //! open instead of abandoning or half-applying it.
12 +
13 + use egui;
14 +
15 + use super::theme;
16 + use crate::state::BrowserState;
17 +
18 + /// Draw the migration strip. No-op when no migration is running.
19 + pub fn draw_layout_strip(ui: &mut egui::Ui, state: &mut BrowserState) {
20 + let Some(progress) = state.layout_migration else {
21 + return;
22 + };
23 +
24 + ui.add_space(theme::space::hair());
25 + ui.horizontal(|ui| {
26 + ui.label(
27 + egui::RichText::new("Optimising storage layout")
28 + .small()
29 + .color(theme::content_secondary()),
30 + );
31 + ui.label(
32 + egui::RichText::new(format!("{} / {} files", progress.completed, progress.total))
33 + .small()
34 + .color(theme::content_muted()),
35 + );
36 +
37 + // The button is laid out first from the right so the bar takes the
38 + // remaining width instead of pushing the button off the row.
39 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
40 + if ui
41 + .small_button("Pause")
42 + .on_hover_text("Stop for now. Resumes the next time this vault opens.")
43 + .clicked()
44 + {
45 + if let Err(e) = state.backend.cancel_layout_migration() {
46 + tracing::warn!("failed to cancel layout migration: {e}");
47 + }
48 + // Clear the strip immediately rather than waiting for the
49 + // worker's terminal event: the click has to feel like it did
50 + // something, and the Complete event that follows sets the status
51 + // line and would clear this anyway.
52 + state.layout_migration = None;
53 + }
54 + ui.add(
55 + egui::ProgressBar::new(progress.fraction())
56 + .desired_height(theme::space::peer())
57 + .show_percentage(),
58 + );
59 + });
60 + });
61 + ui.add_space(theme::space::hair());
62 + }