Skip to content

Commit 146cdd0

Browse files
allisonallison
authored andcommitted
feat(scan): add slow disk mode (#369)
## Summary Adds a slow disk mode setting that groups scan paths by physical disk and assigns one metadata reader thread per disk. Replaces previous behaviour which was one thread per CPU core. Closes #335. ## Changes <!-- What specific changes were made? --> ## Testing <!-- How did you test these changes? --> ## Checklist - [x] No new warnings or clippy lints introduced - if so, explain why - [x] Code works on all supported platforms (Linux, macOS, Windows) - tested on available platforms - [ ] Documentation added/updated where needed - [x] If using generative AI assistance, disclosure is provided (co-authored-by tag or PR description) - see [here](CONTRIBUTING.md) - Windows code was generated by K2.6 Reviewed-on: https://codeberg.org/hummingbird/hummingbird/pulls/369
1 parent 7024bd1 commit 146cdd0

10 files changed

Lines changed: 546 additions & 82 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ windows-result = "0.4"
110110
winreg = { version = "0.56", optional = true }
111111

112112
[target.'cfg(target_os = "macos")'.dependencies]
113+
libc = "0.2"
113114
core-text = "=21.0.0" # GPUI issue, zed#47168
114115
block2 = "0.6"
115116
imagesize = "0.14"

src/library/scan.rs

Lines changed: 245 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub(crate) mod database;
22
mod decode;
33
mod discover;
4+
mod disk;
45
mod record;
56

67
use std::{
@@ -234,6 +235,38 @@ async fn resolve_missing_folder_action(
234235
}
235236
}
236237

238+
/// Shared metadata reader loop used by both normal and slow-disk scanning modes.
239+
/// The caller provides a `recv` closure that abstracts over how paths are received
240+
/// (direct receiver vs. mutex-guarded shared receiver).
241+
fn run_metadata_reader(
242+
mut recv: impl FnMut() -> Option<(Utf8PathBuf, SystemTime)>,
243+
meta_tx: Sender<(Utf8PathBuf, SystemTime, FileInformation)>,
244+
decode_fail_tx: Sender<(Utf8PathBuf, SystemTime)>,
245+
cancel_flag: Arc<AtomicBool>,
246+
) {
247+
let mut art_cache: FxHashMap<Utf8PathBuf, Option<Arc<[u8]>>> = FxHashMap::default();
248+
loop {
249+
if cancel_flag.load(Ordering::Relaxed) {
250+
break;
251+
}
252+
253+
let Some((path, timestamp)) = recv() else {
254+
break;
255+
};
256+
257+
if let Some(info) = read_metadata_for_path(&path, &mut art_cache) {
258+
if meta_tx.blocking_send((path, timestamp, info)).is_err() {
259+
break;
260+
}
261+
} else {
262+
warn!("Could not read metadata for file: {:?}", path);
263+
if decode_fail_tx.blocking_send((path, timestamp)).is_err() {
264+
break;
265+
}
266+
}
267+
}
268+
}
269+
237270
async fn run_scanner(
238271
pool: SqlitePool,
239272
mut scan_settings: ScanSettings,
@@ -445,85 +478,159 @@ async fn run_scanner(
445478
.clamp(2, 8)
446479
- 1;
447480

448-
// we run the discovery and metadata reading stages in separate tasks, that way they can
449-
// run concurrently and no step in the scanning process blocks the other
450-
let (path_tx, path_rx) = tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64);
481+
let meta_capacity = if scan_settings.slow_disk_mode {
482+
64
483+
} else {
484+
num_workers * 8
485+
};
451486
let (meta_tx, mut meta_rx) =
452-
tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime, FileInformation)>(
453-
num_workers * 8,
454-
);
487+
tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime, FileInformation)>(meta_capacity);
455488
// Channel for files that failed metadata decoding - these should be added to scan_record
456489
// immediately since rescanning won't help until the file changes
457490
let (decode_fail_tx, mut decode_fail_rx) =
458-
tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(num_workers * 8);
491+
tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(meta_capacity);
459492

460493
let cancel_flag = Arc::new(AtomicBool::new(false));
461494

462-
// Discovery
463-
let cancel_for_discover = Arc::clone(&cancel_flag);
464-
let discover_handle = match &mode {
465-
ScanMode::Full { .. } => {
466-
let mut settings_for_discover = scan_settings.clone();
467-
settings_for_discover.paths = full_available_paths;
468-
let scan_record_for_discover = scan_record_shared.clone();
469-
spawn_blocking(move || {
470-
discover(
471-
settings_for_discover,
472-
scan_record_for_discover,
473-
path_tx,
474-
cancel_for_discover,
475-
)
476-
})
477-
}
478-
ScanMode::Targeted { paths } => {
479-
let paths = paths.clone();
480-
spawn_blocking(move || rescan_discover(paths, path_tx, cancel_for_discover))
495+
// we run the discovery and metadata reading stages in separate tasks, that way they can
496+
// run concurrently and no step in the scanning process blocks the other
497+
let spawn_discover = |path_tx: tokio::sync::mpsc::Sender<(Utf8PathBuf, SystemTime)>,
498+
cancel: Arc<AtomicBool>|
499+
-> tokio::task::JoinHandle<u64> {
500+
let settings = scan_settings.clone();
501+
let paths = full_available_paths.clone();
502+
let sr = scan_record_shared.clone();
503+
match &mode {
504+
ScanMode::Full { .. } => {
505+
let mut settings = settings;
506+
settings.paths = paths;
507+
spawn_blocking(move || discover(settings, sr, path_tx, cancel))
508+
}
509+
ScanMode::Targeted { paths } => {
510+
let paths = paths.clone();
511+
spawn_blocking(move || rescan_discover(paths, path_tx, cancel))
512+
}
481513
}
482514
};
483515

484-
let path_rx_shared = Arc::new(Mutex::new(path_rx));
485-
486-
for _ in 0..num_workers {
487-
let path_rx = Arc::clone(&path_rx_shared);
488-
let meta_tx = meta_tx.clone();
489-
let decode_fail_tx = decode_fail_tx.clone();
490-
let cancel_flag = Arc::clone(&cancel_flag);
491-
spawn_blocking(move || {
492-
let mut art_cache: FxHashMap<Utf8PathBuf, Option<Arc<[u8]>>> = FxHashMap::default();
493-
loop {
494-
if cancel_flag.load(Ordering::Relaxed) {
495-
break;
496-
}
516+
let mut slow_discover_task: Option<tokio::task::JoinHandle<u64>> = None;
497517

498-
let item = {
499-
let mut rx = path_rx.blocking_lock();
500-
rx.blocking_recv()
501-
};
502-
let Some((path, timestamp)) = item else {
503-
break; // channel closed, discovery complete
504-
};
518+
let (discover_handle, path_rx_shared) = if scan_settings.slow_disk_mode {
519+
let paths_for_disks = full_available_paths.clone();
520+
let (disk_groups, mounts_sorted, mount_to_channel) =
521+
tokio::task::spawn_blocking(move || disk::group_paths_by_disk(&paths_for_disks))
522+
.await
523+
.expect("disk grouping task panicked");
524+
let num_disks = disk_groups.len().max(1);
525+
526+
// Create per-disk channels and collect receivers
527+
let mut disk_txs: Vec<tokio::sync::mpsc::Sender<(Utf8PathBuf, SystemTime)>> =
528+
Vec::with_capacity(num_disks);
529+
let mut disk_rxs: Vec<tokio::sync::mpsc::Receiver<(Utf8PathBuf, SystemTime)>> =
530+
Vec::with_capacity(num_disks);
531+
for _ in 0..num_disks {
532+
let (tx, rx) = tokio::sync::mpsc::channel(64);
533+
disk_txs.push(tx);
534+
disk_rxs.push(rx);
535+
}
505536

506-
if cancel_flag.load(Ordering::Relaxed) {
537+
// Shared discover path_tx / path_rx
538+
let (path_tx, mut path_rx) =
539+
tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64);
540+
541+
let cancel_for_discover = Arc::clone(&cancel_flag);
542+
let discover_task = spawn_discover(path_tx, cancel_for_discover);
543+
slow_discover_task = Some(discover_task);
544+
545+
let router_cancel = Arc::clone(&cancel_flag);
546+
let router_disk_txs = disk_txs.clone();
547+
let router = spawn_blocking(move || {
548+
let mut dir_cache: FxHashMap<Utf8PathBuf, usize> = FxHashMap::default();
549+
let mut routed: u64 = 0;
550+
551+
while let Some((path, timestamp)) = path_rx.blocking_recv() {
552+
if router_cancel.load(Ordering::Relaxed) {
507553
break;
508554
}
509555

510-
if let Some(info) = read_metadata_for_path(&path, &mut art_cache) {
511-
if cancel_flag.load(Ordering::Relaxed) {
512-
break;
513-
}
514-
515-
if meta_tx.blocking_send((path, timestamp, info)).is_err() {
516-
break;
517-
}
518-
} else {
519-
warn!("Could not read metadata for file: {:?}", path);
520-
if decode_fail_tx.blocking_send((path, timestamp)).is_err() {
521-
break;
522-
}
556+
// Find the mount point for this path (cached by parent dir)
557+
let parent = path.parent().map(|p| p.to_path_buf());
558+
let disk_idx = parent
559+
.as_ref()
560+
.and_then(|p| dir_cache.get(p).copied())
561+
.or_else(|| {
562+
// paths from discovery are already canonical
563+
let mount_point = mounts_sorted
564+
.iter()
565+
.find(|m| path.as_std_path().starts_with(m.as_std_path()))?;
566+
let channel = match mount_to_channel.get(mount_point).copied() {
567+
Some(ch) => ch,
568+
None => {
569+
warn!(
570+
"no physical device ID for mount point {:?}, routing to fallback channel 0",
571+
mount_point
572+
);
573+
0
574+
}
575+
};
576+
if let Some(p) = &parent {
577+
dir_cache.insert(p.clone(), channel);
578+
}
579+
Some(channel)
580+
})
581+
.unwrap_or(0);
582+
583+
if router_disk_txs[disk_idx]
584+
.blocking_send((path, timestamp))
585+
.is_err()
586+
{
587+
break;
523588
}
589+
routed += 1;
524590
}
591+
592+
routed
525593
});
526-
}
594+
595+
for mut rx in disk_rxs {
596+
let meta_tx = meta_tx.clone();
597+
let decode_fail_tx = decode_fail_tx.clone();
598+
let cancel_flag = Arc::clone(&cancel_flag);
599+
spawn_blocking(move || {
600+
run_metadata_reader(|| rx.blocking_recv(), meta_tx, decode_fail_tx, cancel_flag)
601+
});
602+
}
603+
604+
(router, None)
605+
} else {
606+
let (path_tx, path_rx) = tokio::sync::mpsc::channel::<(Utf8PathBuf, SystemTime)>(64);
607+
608+
let cancel_for_discover = Arc::clone(&cancel_flag);
609+
let discover = spawn_discover(path_tx, cancel_for_discover);
610+
611+
let path_rx_shared = Arc::new(Mutex::new(path_rx));
612+
613+
for _ in 0..num_workers {
614+
let path_rx = Arc::clone(&path_rx_shared);
615+
let meta_tx = meta_tx.clone();
616+
let decode_fail_tx = decode_fail_tx.clone();
617+
let cancel_flag = Arc::clone(&cancel_flag);
618+
spawn_blocking(move || {
619+
run_metadata_reader(
620+
|| {
621+
let mut rx = path_rx.blocking_lock();
622+
rx.blocking_recv()
623+
},
624+
meta_tx,
625+
decode_fail_tx,
626+
cancel_flag,
627+
)
628+
});
629+
}
630+
631+
(discover, Some(path_rx_shared))
632+
};
633+
527634
// Drop the original senders so the channels close when all worker clones are dropped.
528635
drop(meta_tx);
529636
drop(decode_fail_tx);
@@ -711,11 +818,16 @@ async fn run_scanner(
711818
}
712819

713820
cancel_flag.store(true, Ordering::Relaxed);
714-
drop(path_rx_shared);
821+
if let Some(path_rx_shared) = path_rx_shared {
822+
drop(path_rx_shared);
823+
}
715824

716825
if !discovery_complete {
717826
let _ = discover_handle.await.expect("discover task panicked");
718827
}
828+
if let Some(task) = slow_discover_task {
829+
let _ = task.await.expect("discover task panicked");
830+
}
719831

720832
// drain remaining decode failures
721833
while let Ok((path, timestamp)) = decode_fail_rx.try_recv() {
@@ -815,3 +927,74 @@ pub fn start_scanner(pool: SqlitePool, settings: ScanSettings) -> ScanInterface
815927

816928
ScanInterface::new(Some(events_rx), cmd_tx)
817929
}
930+
931+
#[cfg(test)]
932+
mod tests {
933+
use super::*;
934+
use crate::test_support::TestDir;
935+
936+
#[test]
937+
fn run_metadata_reader_exits_on_cancel_flag() {
938+
let cancel_flag = Arc::new(AtomicBool::new(true));
939+
let (meta_tx, _meta_rx) = channel(1);
940+
let (fail_tx, _fail_rx) = channel(1);
941+
942+
// recv should never be called because cancel_flag is set
943+
let mut called = false;
944+
run_metadata_reader(
945+
|| {
946+
called = true;
947+
None
948+
},
949+
meta_tx,
950+
fail_tx,
951+
cancel_flag,
952+
);
953+
954+
assert!(!called, "recv should not be called when cancelled");
955+
}
956+
957+
#[test]
958+
fn run_metadata_reader_exits_on_channel_close() {
959+
let cancel_flag = Arc::new(AtomicBool::new(false));
960+
let (meta_tx, _meta_rx) = channel(1);
961+
let (fail_tx, _fail_rx) = channel(1);
962+
963+
// recv returns None simulating a closed channel
964+
run_metadata_reader(|| None, meta_tx, fail_tx, cancel_flag);
965+
// function should return without panicking
966+
}
967+
968+
#[test]
969+
fn run_metadata_reader_forwards_decode_failure() {
970+
let cancel_flag = Arc::new(AtomicBool::new(false));
971+
let (meta_tx, _meta_rx) = channel(1);
972+
let (fail_tx, mut fail_rx) = channel(1);
973+
974+
let dir = TestDir::new("decode-fail-test");
975+
let nonexistent = dir.utf8_join("nonexistent.flac");
976+
let ts = SystemTime::now();
977+
let path_for_recv = nonexistent.clone();
978+
979+
let mut call_count = 0;
980+
run_metadata_reader(
981+
move || {
982+
call_count += 1;
983+
if call_count == 1 {
984+
Some((path_for_recv.clone(), ts))
985+
} else {
986+
None // close after one item
987+
}
988+
},
989+
meta_tx,
990+
fail_tx,
991+
cancel_flag,
992+
);
993+
994+
let received = fail_rx
995+
.try_recv()
996+
.expect("should have received decode failure");
997+
assert_eq!(received.0, nonexistent);
998+
assert_eq!(received.1, ts);
999+
}
1000+
}

src/library/scan/decode.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ pub fn read_metadata_for_path(
139139
art_cache: &mut FxHashMap<Utf8PathBuf, Option<Arc<[u8]>>>,
140140
) -> Option<FileInformation> {
141141
if let Ok(mut metadata) = scan_path(path) {
142+
// Only scan for directory-level album art when the DB writer will use it
143+
// (track 1 or unknown, disc 1 or unknown; see database.rs insert_track guard).
142144
if metadata.2.is_none()
145+
&& metadata.0.track_current.is_none_or(|t| t == 1 || t == 0)
146+
&& metadata.0.disc_current.is_none_or(|d| d == 1 || d == 0)
143147
&& let Some(art) = scan_path_for_album_art(path, art_cache)
144148
{
145149
metadata.2 = Some(art.to_vec().into_boxed_slice());

0 commit comments

Comments
 (0)