Skip to main content

dua/
walk.rs

1//! Parallel filesystem traversal backed by a work-stealing worker pool.
2//!
3//! [`walk`] yields the root first, then workers read directories and distribute newly discovered
4//! subdirectories among themselves. [`Order::ParentFirst`] publishes each directory's entries
5//! before scheduling its children, while [`Order::Completion`] allows descendant batches to arrive
6//! first when their reads finish sooner. Sibling order is unspecified in both modes.
7//!
8//! The `descend` predicate controls which directories are traversed; rejected directories are
9//! still yielded (but not traversed).
10//! Symbolic links are reported but never followed, and filesystem errors are
11//! returned as iterator items. Dropping the iterator stops and joins its workers.
12//!
13//! # Scheduling
14//!
15//! The root directory starts in a shared injector queue. Each worker takes jobs from its local
16//! FIFO queue, then the injector, then other workers. Reading a directory schedules each accepted
17//! child directory on the current worker and requests a worker to wake so it can steal available
18//! work. A worker parks when no queue has work and is unparked when new work arrives or the walk
19//! stops. The last completed job emits the finished event; dropping the iterator stops all workers,
20//! unparks them, and joins their threads.
21
22use crossbeam::deque::{Injector, Steal, Stealer, Worker};
23use std::{
24    ffi::OsString,
25    fs::{self, FileType, Metadata},
26    io,
27    path::{Path, PathBuf},
28    sync::{
29        Arc, Mutex,
30        atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
31        mpsc::{Receiver, SyncSender, sync_channel},
32    },
33    thread,
34};
35
36type Descend = dyn Fn(&Entry) -> bool + Send + Sync;
37type Batch = io::Result<Vec<io::Result<Entry>>>;
38
39/// Controls when entries are yielded relative to their descendants.
40#[derive(Clone, Copy)]
41pub enum Order {
42    /// Yield entries as their parent-directory reads complete.
43    Completion,
44    /// Yield every parent before its descendants.
45    ParentFirst,
46}
47
48/// A filesystem entry produced by [`walk`].
49pub struct Entry {
50    /// Number of ancestors below the walk root.
51    pub depth: usize,
52    /// File name relative to `parent_path`.
53    pub file_name: OsString,
54    /// Filesystem entry type without following symbolic links.
55    pub file_type: FileType,
56    /// Entry metadata, or the error encountered while reading it.
57    pub metadata: io::Result<Metadata>,
58    /// Path containing this entry.
59    pub parent_path: Arc<Path>,
60}
61
62struct Job {
63    path: Arc<Path>,
64    depth: usize,
65}
66
67enum Event {
68    Batch(Batch),
69    Finished,
70}
71
72struct PoolShared {
73    /// Global queue that makes the initial root job available to whichever worker starts first.
74    injector: Injector<Job>,
75    stealers: Vec<Stealer<Job>>,
76    stop: AtomicBool,
77    descend: Arc<Descend>,
78    events: SyncSender<Event>,
79    /// Number of directory jobs that are queued or running.
80    pending: AtomicUsize,
81    order: Order,
82    threads: Mutex<Vec<thread::Thread>>,
83    /// A round-robbin counter for the next thread to wake.
84    next_wake: AtomicUsize,
85}
86
87struct Pool {
88    shared: Arc<PoolShared>,
89    handles: Vec<thread::JoinHandle<()>>,
90}
91
92/// A directory iterator whose directory reads happen in parallel.
93pub struct Walk {
94    /// Entries buffered for delivery.
95    ///
96    /// This vector is used as a stack: it starts with the root, and received batches are inserted
97    /// in reverse so popping preserves their original order.
98    ///
99    /// If consumption isn't as fast as its production, threads will block.
100    next: Vec<io::Result<Entry>>,
101    /// Worker-event receiver while a directory traversal is active.
102    ///
103    /// It is `None` when the root is not traversed and after the finished event is received.
104    events: Option<Receiver<Event>>,
105    /// Owns the worker threads for as long as traversal is active.
106    ///
107    /// Clearing or dropping it requests shutdown, unparks every worker, and joins their threads.
108    pool: Option<Pool>,
109}
110
111/// Walk `root` without following symlinks.
112pub fn walk(
113    root: &Path,
114    threads: usize,
115    order: Order,
116    descend: impl Fn(&Entry) -> bool + Send + Sync + 'static,
117) -> Walk {
118    let threads = threads.max(1);
119    let root = Entry::from_path(root);
120    let mut pool = None;
121    let mut events = None;
122
123    if let Ok(entry) = &root
124        && entry.file_type.is_dir()
125        && descend(entry)
126    {
127        let (new_pool, event_rx) = start_pool(
128            Job {
129                path: Arc::from(entry.path()),
130                depth: 1,
131            },
132            threads,
133            order,
134            Arc::new(descend),
135        );
136        pool = Some(new_pool);
137        events = Some(event_rx);
138    }
139
140    Walk {
141        next: vec![root],
142        events,
143        pool,
144    }
145}
146
147impl Iterator for Walk {
148    type Item = io::Result<Entry>;
149
150    fn next(&mut self) -> Option<Self::Item> {
151        loop {
152            if let Some(entry) = self.next.pop() {
153                return Some(entry);
154            }
155
156            match self.events.as_ref()?.recv() {
157                Ok(Event::Batch(Ok(entries))) => {
158                    self.next.extend(entries.into_iter().rev());
159                }
160                Ok(Event::Batch(Err(err))) => return Some(Err(err)),
161                Ok(Event::Finished) => {
162                    self.events = None;
163                    self.pool = None;
164                    return None;
165                }
166                Err(_) => return Some(Err(io::Error::other("directory worker stopped"))),
167            }
168        }
169    }
170}
171
172impl PoolShared {
173    /// Unpark one worker, selected round-robin.
174    ///
175    /// Selection does not exclude the caller. Unparking the current thread stores a wake token,
176    /// causing its next [`thread::park`] to return immediately rather than waking a peer. This
177    /// preserves correctness but may delay additional parallelism until a later call advances to
178    /// another worker. Unparking an already-awake worker has the same token semantics.
179    fn wake_one_worker(&self) {
180        let threads = self.threads.lock().expect("worker list lock");
181        if let Some(thread) =
182            threads.get(self.next_wake.fetch_add(1, AtomicOrdering::Relaxed) % threads.len().max(1))
183        {
184            thread.unpark();
185        }
186    }
187
188    fn wake_workers(&self) {
189        for thread in self.threads.lock().expect("worker list lock").iter() {
190            thread.unpark();
191        }
192    }
193}
194
195impl Entry {
196    /// Return the full path to this entry.
197    #[must_use]
198    pub fn path(&self) -> PathBuf {
199        self.parent_path.join(&self.file_name)
200    }
201
202    fn from_path(path: &Path) -> io::Result<Self> {
203        let metadata = fs::symlink_metadata(path)?;
204        Ok(Self {
205            depth: 0,
206            file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(),
207            file_type: metadata.file_type(),
208            metadata: Ok(metadata),
209            parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))),
210        })
211    }
212
213    fn from_dir_entry(
214        depth: usize,
215        parent_path: Arc<Path>,
216        entry: fs::DirEntry,
217    ) -> io::Result<Self> {
218        Ok(Self {
219            depth,
220            file_name: entry.file_name(),
221            file_type: entry.file_type()?,
222            metadata: entry.metadata(),
223            parent_path,
224        })
225    }
226}
227
228fn start_pool(
229    first_job: Job,
230    threads: usize,
231    order: Order,
232    descend: Arc<Descend>,
233) -> (Pool, Receiver<Event>) {
234    let workers: Vec<_> = (0..threads).map(|_| Worker::new_fifo()).collect();
235    let (event_tx, event_rx) = sync_channel(threads * 2);
236    let shared = Arc::new(PoolShared {
237        injector: Injector::new(),
238        stealers: workers.iter().map(Worker::stealer).collect(),
239        stop: AtomicBool::new(false),
240        descend,
241        events: event_tx,
242        pending: AtomicUsize::new(1),
243        order,
244        threads: Mutex::new(Vec::with_capacity(threads)),
245        next_wake: AtomicUsize::new(0),
246    });
247    shared.injector.push(first_job);
248
249    let handles: Vec<_> = workers
250        .into_iter()
251        .enumerate()
252        .map(|(idx, worker)| {
253            let shared = Arc::clone(&shared);
254            thread::Builder::new()
255                .name(format!("dua-fs-walk-{idx}"))
256                .spawn(move || worker_loop(worker, shared))
257                .expect("filesystem worker thread can be spawned")
258        })
259        .collect();
260    *shared.threads.lock().expect("worker list lock") = handles
261        .iter()
262        .map(|handle| handle.thread().clone())
263        .collect();
264    shared.wake_workers();
265
266    (Pool { shared, handles }, event_rx)
267}
268
269fn worker_loop(worker: Worker<Job>, shared: Arc<PoolShared>) {
270    while !shared.stop.load(AtomicOrdering::Relaxed) {
271        if let Some(job) = find_job(&worker, &shared) {
272            run_job(job, &worker, &shared);
273        } else {
274            thread::park();
275        }
276    }
277}
278
279impl Drop for Pool {
280    fn drop(&mut self) {
281        self.shared.stop.store(true, AtomicOrdering::Relaxed);
282        self.shared.wake_workers();
283        for handle in self.handles.drain(..) {
284            handle.join().ok();
285        }
286    }
287}
288
289/// Find work in order of increasing synchronization cost.
290///
291/// The worker checks its own FIFO queue first, preserving local scheduling order and avoiding
292/// shared-queue contention. It next takes a batch from the injector, keeping one job and moving
293/// the rest into its local queue. Only then does it inspect other workers, because stealing from a
294/// peer is the most contentious path. Consequently, a worker with local jobs keeps processing
295/// them before helping elsewhere, and injector jobs take priority over peer jobs.
296fn find_job(worker: &Worker<Job>, shared: &PoolShared) -> Option<Job> {
297    loop {
298        if let Some(job) = worker.pop() {
299            return Some(job);
300        }
301
302        match shared.injector.steal_batch_and_pop(worker) {
303            Steal::Success(job) => return Some(job),
304            Steal::Retry => continue,
305            Steal::Empty => {}
306        }
307
308        let mut retry = false;
309        for stealer in &shared.stealers {
310            match stealer.steal() {
311                Steal::Success(job) => return Some(job),
312                Steal::Retry => retry = true,
313                Steal::Empty => {}
314            }
315        }
316        if !retry {
317            return None;
318        }
319    }
320}
321
322fn run_job(job: Job, worker: &Worker<Job>, shared: &PoolShared) {
323    let (batch, jobs) = read_dir(&job.path, job.depth, shared);
324    shared
325        .pending
326        .fetch_add(jobs.len(), AtomicOrdering::Relaxed);
327
328    match shared.order {
329        Order::ParentFirst => {
330            if shared.events.send(Event::Batch(batch)).is_err() {
331                shared.stop.store(true, AtomicOrdering::Relaxed);
332                return;
333            }
334            schedule_jobs(jobs, worker, shared);
335        }
336        Order::Completion => {
337            schedule_jobs(jobs, worker, shared);
338            if shared.events.send(Event::Batch(batch)).is_err() {
339                shared.stop.store(true, AtomicOrdering::Relaxed);
340                return;
341            }
342        }
343    }
344
345    let only_this_thread_left = shared.pending.fetch_sub(1, AtomicOrdering::Relaxed) == 1;
346    if only_this_thread_left {
347        shared.events.send(Event::Finished).ok();
348    }
349}
350
351fn schedule_jobs(jobs: Vec<Job>, worker: &Worker<Job>, shared: &PoolShared) {
352    let has_jobs = !jobs.is_empty();
353    for job in jobs {
354        worker.push(job);
355    }
356    if has_jobs {
357        shared.wake_one_worker();
358    }
359}
360
361fn read_dir(path: &Arc<Path>, depth: usize, shared: &PoolShared) -> (Batch, Vec<Job>) {
362    let entries = match fs::read_dir(path) {
363        Ok(entries) => entries,
364        Err(err) => return (Err(err), Vec::new()),
365    };
366    let mut jobs = Vec::new();
367    let entries = entries
368        .map(|entry| {
369            entry
370                .and_then(|entry| Entry::from_dir_entry(depth, Arc::clone(path), entry))
371                .inspect(|entry| {
372                    if entry.file_type.is_dir() && (shared.descend)(entry) {
373                        jobs.push(Job {
374                            path: Arc::from(entry.path()),
375                            depth: depth + 1,
376                        });
377                    }
378                })
379        })
380        .collect();
381    (Ok(entries), jobs)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn parallel_walk_is_parent_first_and_does_not_follow_symlinks() {
390        let dir = tempfile::tempdir().unwrap();
391        fs::create_dir_all(dir.path().join("b/child")).unwrap();
392        fs::create_dir(dir.path().join("a")).unwrap();
393        fs::write(dir.path().join("b/child/file"), b"x").unwrap();
394
395        #[cfg(unix)]
396        std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("link")).unwrap();
397
398        #[cfg(unix)]
399        let expected = ["", "a", "b", "b/child", "b/child/file", "link"];
400        #[cfg(not(unix))]
401        let expected = ["", "a", "b", "b/child", "b/child/file"];
402        let expected = expected.into_iter().map(PathBuf::from).collect::<Vec<_>>();
403
404        for threads in [1, 4] {
405            let paths = walk(dir.path(), threads, Order::ParentFirst, |_| true)
406                .map(|entry| {
407                    entry
408                        .unwrap()
409                        .path()
410                        .strip_prefix(dir.path())
411                        .unwrap()
412                        .to_owned()
413                })
414                .collect::<Vec<_>>();
415            let mut sorted_paths = paths.clone();
416            sorted_paths.sort();
417            assert_eq!(
418                sorted_paths, expected,
419                "walk with {threads} threads should visit every expected path exactly once"
420            );
421
422            for path in paths.iter().filter(|path| path.components().count() > 1) {
423                let parent = path.parent().unwrap();
424                assert!(
425                    paths.iter().position(|path| path == parent)
426                        < paths.iter().position(|candidate| candidate == path),
427                    "parent {parent:?} should precede child {path:?} with {threads} threads; \
428                     traversal order: {paths:?}"
429                );
430            }
431        }
432    }
433
434    #[test]
435    fn pruning_keeps_the_directory_and_missing_roots_are_errors() {
436        let dir = tempfile::tempdir().unwrap();
437        fs::create_dir_all(dir.path().join("skip/child")).unwrap();
438
439        let paths = walk(dir.path(), 2, Order::Completion, |entry| {
440            entry.file_name != "skip"
441        })
442        .map(|entry| entry.unwrap().file_name)
443        .collect::<Vec<_>>();
444        assert_eq!(
445            paths,
446            vec![
447                dir.path().file_name().unwrap().to_owned(),
448                OsString::from("skip")
449            ],
450            "a pruned directory should be yielded without traversing its children"
451        );
452
453        assert!(
454            walk(&dir.path().join("missing"), 2, Order::Completion, |_| true)
455                .next()
456                .unwrap()
457                .is_err(),
458            "a missing root should be yielded as an I/O error"
459        );
460    }
461}