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}