Skip to main content

qubit_batch/execute/impls/
parallel_batch_executor.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10use std::sync::Arc;
11use std::thread;
12use std::time::Duration;
13
14use qubit_function::Runnable;
15use qubit_progress::{
16    Progress,
17    reporter::ProgressReporter,
18};
19
20use crate::BatchExecutionError;
21use crate::BatchOutcome;
22use crate::execute::{
23    BatchExecutionState,
24    BatchExecutor,
25    EXECUTION_PROGRESS_METRIC_ID,
26    EXECUTION_PROGRESS_METRIC_NAME,
27    SequentialBatchExecutor,
28};
29use crate::utils::run_scoped_parallel;
30
31use super::ParallelBatchExecutorBuildError;
32use super::ParallelBatchExecutorBuilder;
33use super::indexed_task::run_parallel_task;
34
35/// Fixed-width parallel batch executor backed by scoped standard threads.
36///
37/// The executor creates scoped worker threads for each parallel batch run and
38/// shuts them down before [`BatchExecutor::execute`] returns. Because the
39/// workers are scoped to the call, tasks may borrow data from the caller and do
40/// not need to be `'static`.
41///
42/// [`Default`] uses [`Self::DEFAULT_SEQUENTIAL_THRESHOLD`], so batches with at
43/// most 100 declared tasks run through [`SequentialBatchExecutor`] to avoid
44/// thread setup overhead. Configure `sequential_threshold(0)` through
45/// [`Self::builder`] when every non-empty batch should use parallel workers.
46///
47/// ```rust
48/// use qubit_batch::{
49///     BatchExecutor,
50///     ParallelBatchExecutor,
51/// };
52///
53/// let executor = ParallelBatchExecutor::builder()
54///     .thread_count(2)
55///     .sequential_threshold(0)
56///     .build()
57///     .expect("parallel executor configuration should be valid");
58///
59/// let outcome = executor
60///     .for_each(0..4, |value| {
61///         assert!(value < 4);
62///         Ok::<(), &'static str>(())
63///     })
64///     .expect("range length should be exact");
65///
66/// assert!(outcome.is_success());
67/// ```
68#[derive(Clone)]
69pub struct ParallelBatchExecutor {
70    /// Number of worker threads used for parallel executions.
71    pub(crate) thread_count: usize,
72    /// Maximum batch size that still uses sequential execution.
73    pub(crate) sequential_threshold: usize,
74    /// Minimum interval between progress callbacks.
75    pub(crate) report_interval: Duration,
76    /// Reporter receiving batch lifecycle callbacks.
77    pub(crate) reporter: Arc<dyn ProgressReporter>,
78}
79
80impl ParallelBatchExecutor {
81    /// Default interval between progress callbacks.
82    pub const DEFAULT_REPORT_INTERVAL: Duration = Duration::from_secs(5);
83
84    /// Default maximum batch size that still uses sequential execution.
85    pub const DEFAULT_SEQUENTIAL_THRESHOLD: usize = 100;
86
87    /// Returns the default worker-thread count.
88    ///
89    /// # Returns
90    ///
91    /// The available CPU parallelism, or `1` if it cannot be detected.
92    #[inline]
93    pub fn default_thread_count() -> usize {
94        thread::available_parallelism().map(usize::from).unwrap_or(1)
95    }
96
97    /// Creates a builder for configuring a parallel batch executor.
98    ///
99    /// # Returns
100    ///
101    /// A builder initialized with default settings.
102    #[inline]
103    pub fn builder() -> ParallelBatchExecutorBuilder {
104        ParallelBatchExecutorBuilder::default()
105    }
106
107    /// Creates a parallel batch executor with `thread_count` workers.
108    ///
109    /// # Parameters
110    ///
111    /// * `thread_count` - Number of scoped worker threads to use.
112    ///
113    /// # Returns
114    ///
115    /// A configured parallel batch executor.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`ParallelBatchExecutorBuildError::ZeroThreadCount`] when
120    /// `thread_count` is zero.
121    #[inline]
122    pub fn new(thread_count: usize) -> Result<Self, ParallelBatchExecutorBuildError> {
123        Self::builder().thread_count(thread_count).build()
124    }
125
126    /// Returns the configured worker-thread count.
127    ///
128    /// # Returns
129    ///
130    /// The maximum number of scoped worker threads used for one batch.
131    #[inline]
132    pub const fn thread_count(&self) -> usize {
133        self.thread_count
134    }
135
136    /// Returns the configured sequential fallback threshold.
137    ///
138    /// # Returns
139    ///
140    /// The maximum task count that still runs sequentially.
141    #[inline]
142    pub const fn sequential_threshold(&self) -> usize {
143        self.sequential_threshold
144    }
145
146    /// Returns the configured progress-report interval.
147    ///
148    /// # Returns
149    ///
150    /// The minimum interval between due-based running progress callbacks.
151    #[inline]
152    pub const fn report_interval(&self) -> Duration {
153        self.report_interval
154    }
155
156    /// Returns the progress reporter used by this executor.
157    ///
158    /// # Returns
159    ///
160    /// A shared reference to the configured progress reporter.
161    #[inline]
162    pub fn reporter(&self) -> &Arc<dyn ProgressReporter> {
163        &self.reporter
164    }
165
166    /// Creates a sequential executor with matching progress configuration.
167    ///
168    /// # Returns
169    ///
170    /// A sequential executor used for small batches.
171    fn sequential_executor(&self) -> SequentialBatchExecutor {
172        SequentialBatchExecutor::builder()
173            .report_interval(self.report_interval)
174            .reporter_arc(Arc::clone(&self.reporter))
175            .build()
176    }
177}
178
179impl Default for ParallelBatchExecutor {
180    /// Creates a default parallel batch executor.
181    ///
182    /// # Returns
183    ///
184    /// A default-configured parallel batch executor.
185    ///
186    /// # Panics
187    ///
188    /// Panics if the default configuration fails validation.
189    fn default() -> Self {
190        Self::builder()
191            .build()
192            .expect("default parallel batch executor should build")
193    }
194}
195
196impl BatchExecutor for ParallelBatchExecutor {
197    /// Executes the batch on scoped standard threads when the batch is large
198    /// enough.
199    ///
200    /// # Parameters
201    ///
202    /// * `tasks` - Task source for the batch.
203    /// * `count` - Declared task count expected from `tasks`.
204    ///
205    /// # Returns
206    ///
207    /// A structured batch result when the declared task count matches, or a
208    /// batch-count mismatch error with the attached partial result.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`BatchExecutionError`] when `tasks` yields fewer or more tasks
213    /// than `count`.
214    ///
215    /// # Panics
216    ///
217    /// Panics from tasks are captured in the result. Panics from the configured
218    /// progress reporter are propagated to the caller.
219    fn execute_with_count<T, E, I>(&self, tasks: I, count: usize) -> Result<BatchOutcome<E>, BatchExecutionError<E>>
220    where
221        I: IntoIterator<Item = T>,
222        T: Runnable<E> + Send,
223        E: Send,
224    {
225        if count <= self.sequential_threshold || self.thread_count <= 1 {
226            return self.sequential_executor().execute_with_count(tasks, count);
227        }
228
229        let state = Arc::new(BatchExecutionState::new(count));
230        let progress = Progress::single_metric(
231            self.reporter.as_ref(),
232            self.report_interval,
233            EXECUTION_PROGRESS_METRIC_ID,
234            EXECUTION_PROGRESS_METRIC_NAME,
235        );
236        progress.report_started(|event| event.counters(state.progress_counters()));
237        let mut actual_count = 0usize;
238        let worker_count = self.thread_count.min(count);
239
240        thread::scope(|scope| {
241            let reporter_state = Arc::clone(&state);
242            let running_progress = progress.spawn_running_reporter(scope, move || reporter_state.progress_counters());
243            let running_point_handle = running_progress.point_handle();
244
245            let observer_state = Arc::clone(&state);
246            let worker_state = Arc::clone(&state);
247            actual_count = run_scoped_parallel(
248                tasks,
249                count,
250                worker_count,
251                move || observer_state.record_task_observed(),
252                move |index, task| {
253                    run_parallel_task(&worker_state, index, task);
254                    running_point_handle.report();
255                },
256            );
257            running_progress.stop_and_join();
258        });
259
260        let state = Arc::into_inner(state).expect("parallel batch execution state should have a single owner");
261        if actual_count < count {
262            let failed = progress.report_failed(|event| event.counters(state.progress_counters()));
263            let result = state.into_outcome(failed.elapsed());
264            Err(BatchExecutionError::CountShortfall {
265                expected: count,
266                actual: actual_count,
267                outcome: result,
268            })
269        } else if actual_count > count {
270            let failed = progress.report_failed(|event| event.counters(state.progress_counters()));
271            let result = state.into_outcome(failed.elapsed());
272            Err(BatchExecutionError::CountExceeded {
273                expected: count,
274                observed_at_least: actual_count,
275                outcome: result,
276            })
277        } else {
278            let finished = progress.report_finished(|event| event.counters(state.progress_counters()));
279            let result = state.into_outcome(finished.elapsed());
280            Ok(result)
281        }
282    }
283}