Skip to main content

weavatrix_scan/
parallel.rs

1use crate::FileIdentity;
2use crate::runtime::ParallelRuntime;
3use crate::walker::{ErrorPolicy, WalkEntry, WalkError, WalkOperation, WalkOptions};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex, mpsc};
6
7mod collect;
8pub(crate) mod dynamic;
9mod ordered_pull;
10mod pull;
11mod visit;
12mod visit_worker;
13
14use collect::{DirectoryTask, collect_lane, collect_serial, collect_shallow, expand_frontier};
15pub use pull::ParallelWalkIter;
16pub use visit::{ParallelVisitReport, WalkControl, WalkEvent};
17
18/// Collected output from a parallel filesystem walk.
19#[derive(Debug)]
20pub struct ParallelWalkReport {
21    pub entries: Vec<WalkEntry>,
22    pub errors: Vec<WalkError>,
23}
24
25/// An adaptive parallel filesystem walker.
26///
27/// Broad root frontiers use low-overhead lane traversal. Narrow trees use
28/// dynamic directory scheduling so work below one top-level directory can use
29/// every worker. Link-following carries an immutable ancestry set with each
30/// directory task so aliases remain parallel without losing cycle detection.
31pub struct ParallelWalker {
32    pub(super) root: PathBuf,
33    pub(super) options: WalkOptions,
34    pub(super) parallelism: usize,
35    pub(super) skip_stdout: Option<FileIdentity>,
36    pub(super) runtime: ParallelRuntime,
37}
38
39impl ParallelWalker {
40    #[must_use]
41    pub fn new(root: impl Into<PathBuf>) -> Self {
42        Self {
43            root: root.into(),
44            options: WalkOptions::default(),
45            parallelism: 0,
46            skip_stdout: None,
47            runtime: ParallelRuntime::global(),
48        }
49    }
50
51    #[must_use]
52    pub const fn options(mut self, options: WalkOptions) -> Self {
53        self.options = options;
54        self
55    }
56
57    /// Sets worker count. Zero selects available parallelism.
58    #[must_use]
59    pub const fn with_parallelism(mut self, parallelism: usize) -> Self {
60        self.parallelism = parallelism;
61        self
62    }
63
64    /// Selects the global, dedicated, or application-owned executor.
65    #[must_use]
66    pub fn runtime(mut self, runtime: ParallelRuntime) -> Self {
67        self.runtime = runtime;
68        self
69    }
70
71    /// Skips a regular file that refers to redirected standard output.
72    ///
73    /// This applies consistently to collected, visitor and pull APIs and
74    /// prevents feedback loops when command output is written inside a walked
75    /// tree.
76    #[must_use]
77    pub fn skip_stdout(mut self, enabled: bool) -> Self {
78        self.skip_stdout = enabled.then(crate::stdout::identity).flatten();
79        self
80    }
81
82    /// Walks the tree using bounded adaptive scheduling.
83    ///
84    /// # Errors
85    ///
86    /// With `ErrorPolicy::Abort`, returns the first observed local traversal
87    /// error. With `ErrorPolicy::Continue`, local errors are collected.
88    ///
89    /// # Panics
90    ///
91    /// Panics if an internal worker panics or shared traversal state is
92    /// poisoned.
93    pub fn walk(mut self) -> Result<ParallelWalkReport, WalkError> {
94        self.options = self.options.normalized();
95        let skip_stdout = self.skip_stdout;
96        if self.runtime.is_worker_thread() {
97            return collect_serial(&self.root, self.options)
98                .map(|report| without_stdout(report, skip_stdout));
99        }
100        if self.options.follow_links {
101            return self
102                .walk_dynamic()
103                .map(|report| without_stdout(report, skip_stdout));
104        }
105        let mut shallow = collect_shallow(&self.root, self.options)?;
106        if self.options.error_policy == ErrorPolicy::Abort && !shallow.errors.is_empty() {
107            return Err(shallow.errors.into_iter().next().expect("error exists"));
108        }
109        if !self.options.same_file_system && shallow.tasks.len() < 2 {
110            let target = requested_workers(&self.runtime, self.parallelism, self.options.max_open)
111                .min(FRONTIER_TARGET_TASKS);
112            shallow = expand_frontier(shallow, self.options, target);
113        }
114        if shallow.tasks.is_empty() {
115            return Ok(without_stdout(
116                ParallelWalkReport {
117                    entries: shallow.entries,
118                    errors: shallow.errors,
119                },
120                skip_stdout,
121            ));
122        }
123        self.walk_lanes(shallow)
124            .map(|report| without_stdout(report, skip_stdout))
125    }
126
127    fn walk_dynamic(self) -> Result<ParallelWalkReport, WalkError> {
128        let report = Arc::new(Mutex::new(ParallelWalkReport {
129            entries: Vec::new(),
130            errors: Vec::new(),
131        }));
132        let visitor_report = Arc::clone(&report);
133        dynamic::stream_batched(
134            &self.root,
135            self.options,
136            self.parallelism,
137            &self.runtime,
138            &crate::CancellationToken::new(),
139            move |entries, errors| {
140                let mut report = visitor_report
141                    .lock()
142                    .expect("parallel walk report is not poisoned");
143                report.entries.extend(entries);
144                report.errors.extend(errors.iter().map(copy_walk_error));
145                true
146            },
147        )?;
148        let mut report = Arc::try_unwrap(report)
149            .expect("parallel walk visitor released report")
150            .into_inner()
151            .expect("parallel walk report is not poisoned");
152        report
153            .entries
154            .sort_unstable_by(|left, right| left.path().cmp(right.path()));
155        report.errors.sort_unstable_by(|left, right| {
156            left.path()
157                .cmp(right.path())
158                .then_with(|| left.depth().cmp(&right.depth()))
159        });
160        Ok(report)
161    }
162
163    fn walk_lanes(self, shallow: collect::ShallowWalk) -> Result<ParallelWalkReport, WalkError> {
164        let mut entries = shallow.entries;
165        let mut errors = shallow.errors;
166        let task_count = shallow.tasks.len();
167        let worker_count = parallel_worker_count(
168            &self.runtime,
169            self.parallelism,
170            self.options.max_open,
171            task_count,
172        );
173        let mut lanes = (0..worker_count)
174            .map(|_| Vec::<DirectoryTask>::new())
175            .collect::<Vec<_>>();
176        for (index, task) in shallow.tasks.into_iter().enumerate() {
177            lanes[index % worker_count].push(task);
178        }
179        let (sender, receiver) = mpsc::channel();
180        for (index, lane) in lanes.into_iter().enumerate() {
181            let sender = sender.clone();
182            let root = Arc::clone(&shallow.root);
183            let options = self.options;
184            self.runtime
185                .try_execute(move || {
186                    let report = collect_lane(lane, options, &root);
187                    let _ = sender.send((index, report));
188                })
189                .map_err(|source| schedule_error(&self.root, source))?;
190        }
191        drop(sender);
192        let mut completed = (0..worker_count).map(|_| None).collect::<Vec<_>>();
193        for (index, report) in receiver {
194            completed[index] = Some(report);
195        }
196        let mut lanes = completed
197            .into_iter()
198            .map(|lane| {
199                let lane = lane.expect("every parallel lane reports completion");
200                (
201                    lane.report.entries.into_iter(),
202                    lane.report.errors.into_iter(),
203                    lane.segments,
204                )
205            })
206            .collect::<Vec<_>>();
207        for task_index in 0..task_count {
208            let (lane_entries, lane_errors, segments) = &mut lanes[task_index % worker_count];
209            let segment = segments
210                .pop_front()
211                .expect("every directory task has an output segment");
212            entries.extend(lane_entries.by_ref().take(segment.entries));
213            errors.extend(lane_errors.by_ref().take(segment.errors));
214        }
215        if self.options.error_policy == ErrorPolicy::Abort && !errors.is_empty() {
216            return Err(errors.remove(0));
217        }
218        Ok(ParallelWalkReport { entries, errors })
219    }
220}
221
222fn parallel_worker_count(
223    runtime: &ParallelRuntime,
224    parallelism: usize,
225    max_open: usize,
226    tasks: usize,
227) -> usize {
228    requested_workers(runtime, parallelism, max_open)
229        .min(tasks)
230        .max(1)
231}
232
233fn requested_workers(runtime: &ParallelRuntime, parallelism: usize, max_open: usize) -> usize {
234    let available = runtime.parallelism();
235    if parallelism == 0 {
236        available.min(default_traversal_workers())
237    } else {
238        parallelism.min(available)
239    }
240    .min(max_open.max(1))
241    .max(1)
242}
243
244fn schedule_error(root: &std::path::Path, source: std::io::Error) -> WalkError {
245    WalkError::new(root, 0, WalkOperation::ScheduleWorker, source)
246}
247
248const fn default_traversal_workers() -> usize {
249    if cfg!(windows) { 16 } else { 8 }
250}
251
252const FRONTIER_TARGET_TASKS: usize = 4;
253
254fn copy_walk_error(error: &WalkError) -> WalkError {
255    WalkError::new(
256        error.path().to_path_buf(),
257        error.depth(),
258        error.operation(),
259        std::io::Error::new(error.io_error().kind(), error.io_error().to_string()),
260    )
261}
262
263pub(super) fn matches_stdout(entry: &WalkEntry, identity: Option<FileIdentity>) -> bool {
264    identity.is_some_and(|identity| {
265        entry.is_file() && crate::stdout::path_matches(entry.path(), identity).unwrap_or(false)
266    })
267}
268
269fn without_stdout(
270    mut report: ParallelWalkReport,
271    identity: Option<FileIdentity>,
272) -> ParallelWalkReport {
273    if identity.is_some() {
274        report
275            .entries
276            .retain(|entry| !matches_stdout(entry, identity));
277    }
278    report
279}