Skip to main content

qubit_batch/execute/impls/
sequential_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::{
11    panic::{
12        AssertUnwindSafe,
13        catch_unwind,
14    },
15    sync::Arc,
16    time::Duration,
17};
18
19use qubit_function::Runnable;
20use qubit_progress::{
21    Progress,
22    reporter::ProgressReporter,
23};
24
25use crate::{
26    BatchExecutionError,
27    BatchOutcome,
28    execute::{
29        BatchExecutionState,
30        BatchExecutor,
31        EXECUTION_PROGRESS_METRIC_ID,
32        EXECUTION_PROGRESS_METRIC_NAME,
33        panic_payload_to_error,
34    },
35};
36
37use super::SequentialBatchExecutorBuilder;
38
39/// Executes a whole batch sequentially on the caller thread.
40///
41/// Progress updates are emitted only between tasks. A long-running single task
42/// therefore does not produce intermediate sequential progress callbacks.
43///
44/// ```rust
45/// use qubit_batch::{
46///     BatchExecutor,
47///     SequentialBatchExecutor,
48/// };
49///
50/// let outcome = SequentialBatchExecutor::new()
51///     .for_each(["a", "b", "c"], |item| {
52///         assert!(!item.is_empty());
53///         Ok::<(), &'static str>(())
54///     })
55///     .expect("array length should be exact");
56///
57/// assert!(outcome.is_success());
58/// ```
59#[derive(Clone)]
60pub struct SequentialBatchExecutor {
61    /// Interval between progress callbacks while the batch is running.
62    pub(crate) report_interval: Duration,
63    /// Reporter receiving batch lifecycle callbacks.
64    pub(crate) reporter: Arc<dyn ProgressReporter>,
65}
66
67impl SequentialBatchExecutor {
68    /// Default interval between progress callbacks.
69    pub const DEFAULT_REPORT_INTERVAL: Duration = Duration::from_secs(5);
70
71    /// Creates a sequential batch executor with default configuration.
72    ///
73    /// # Returns
74    ///
75    /// A sequential batch executor using no-op progress reporting.
76    #[inline]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Creates a builder for configuring a sequential batch executor.
82    ///
83    /// # Returns
84    ///
85    /// A builder initialized with default settings.
86    #[inline]
87    pub fn builder() -> SequentialBatchExecutorBuilder {
88        SequentialBatchExecutorBuilder::default()
89    }
90
91    /// Returns the configured progress-report interval.
92    ///
93    /// # Returns
94    ///
95    /// The minimum time between due-based running progress callbacks.
96    #[inline]
97    pub const fn report_interval(&self) -> Duration {
98        self.report_interval
99    }
100
101    /// Returns the progress reporter used by this executor.
102    ///
103    /// # Returns
104    ///
105    /// A shared reference to the configured progress reporter.
106    #[inline]
107    pub fn reporter(&self) -> &Arc<dyn ProgressReporter> {
108        &self.reporter
109    }
110}
111
112impl Default for SequentialBatchExecutor {
113    /// Creates a sequential batch executor with default configuration.
114    ///
115    /// # Returns
116    ///
117    /// A sequential batch executor using no-op progress reporting.
118    #[inline]
119    fn default() -> Self {
120        Self::builder().build()
121    }
122}
123
124impl BatchExecutor for SequentialBatchExecutor {
125    /// Executes the batch sequentially on the caller thread.
126    ///
127    /// # Parameters
128    ///
129    /// * `tasks` - Task source for the batch.
130    /// * `count` - Declared task count expected from `tasks`.
131    ///
132    /// # Returns
133    ///
134    /// A structured batch result when the declared task count matches, or a
135    /// batch-count mismatch error with the attached partial result.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`BatchExecutionError`] when `tasks` yields fewer or more tasks
140    /// than `count`.
141    ///
142    /// # Panics
143    ///
144    /// Panics from tasks are captured in the result. Panics from the configured
145    /// progress reporter are propagated to the caller.
146    fn execute_with_count<T, E, I>(&self, tasks: I, count: usize) -> Result<BatchOutcome<E>, BatchExecutionError<E>>
147    where
148        I: IntoIterator<Item = T>,
149        T: Runnable<E> + Send,
150        E: Send,
151    {
152        let state = BatchExecutionState::new(count);
153        let mut progress = Progress::single_metric(
154            self.reporter.as_ref(),
155            self.report_interval,
156            EXECUTION_PROGRESS_METRIC_ID,
157            EXECUTION_PROGRESS_METRIC_NAME,
158        );
159        progress.report_started(|event| event.counters(state.progress_counters()));
160        let mut actual_count = 0;
161        for task in tasks {
162            actual_count = state.record_task_observed();
163            if actual_count > count {
164                let failed = progress.report_failed(|event| event.counters(state.progress_counters()));
165                let outcome = state.into_outcome(failed.elapsed());
166                return Err(BatchExecutionError::CountExceeded {
167                    expected: count,
168                    observed_at_least: actual_count,
169                    outcome,
170                });
171            }
172            // Execute the task and update the state.
173            let mut task = task;
174            state.record_task_started();
175            match catch_unwind(AssertUnwindSafe(|| task.run())) {
176                Ok(Ok(())) => state.record_task_succeeded(),
177                Ok(Err(error)) => state.record_task_failed(actual_count - 1, error),
178                Err(payload) => state.record_task_panicked(actual_count - 1, panic_payload_to_error(payload.as_ref())),
179            }
180            // Update the actual task count and report progress if due.
181            let _ = progress.report_running_if_due(|event| event.counters(state.progress_counters()));
182        }
183
184        if actual_count < count {
185            let failed = progress.report_failed(|event| event.counters(state.progress_counters()));
186            Err(BatchExecutionError::CountShortfall {
187                expected: count,
188                actual: actual_count,
189                outcome: state.into_outcome(failed.elapsed()),
190            })
191        } else {
192            let finished = progress.report_finished(|event| event.counters(state.progress_counters()));
193            Ok(state.into_outcome(finished.elapsed()))
194        }
195    }
196}