Skip to main content

rskit_pipeline/
executor.rs

1//! Sequential step-based executor with progress reporting and cancellation.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::time::{Duration, Instant};
6
7use rskit_errors::{AppError, AppResult, ErrorCode};
8use tokio_util::sync::CancellationToken;
9
10/// The type of the async step function.
11pub type StepFn<T> =
12    Box<dyn Fn(T) -> Pin<Box<dyn Future<Output = AppResult<T>> + Send>> + Send + Sync>;
13
14/// A named step in an executable pipeline.
15pub struct Step<T> {
16    id: String,
17    name: String,
18    execute: StepFn<T>,
19}
20
21impl<T> Step<T> {
22    /// Create a sequential pipeline step.
23    #[must_use]
24    pub fn new(id: impl Into<String>, name: impl Into<String>, execute: StepFn<T>) -> Self {
25        Self {
26            id: id.into(),
27            name: name.into(),
28            execute,
29        }
30    }
31
32    /// Unique identifier for this step.
33    #[must_use]
34    pub fn id(&self) -> &str {
35        &self.id
36    }
37
38    /// Human-readable name for progress reporting.
39    #[must_use]
40    pub fn name(&self) -> &str {
41        &self.name
42    }
43}
44
45/// Progress report emitted before and after each step.
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub enum StepStatus {
49    /// A step is about to start.
50    Started {
51        /// ID of the step.
52        step_id: String,
53    },
54    /// A step completed successfully.
55    Completed {
56        /// ID of the step.
57        step_id: String,
58        /// Wall-clock time spent in this step.
59        elapsed: Duration,
60    },
61    /// A step failed.
62    Failed {
63        /// ID of the step.
64        step_id: String,
65        /// Error description.
66        error: String,
67        /// Wall-clock time before the failure.
68        elapsed: Duration,
69    },
70}
71
72/// Sequential executor that runs [`Step`]s in order, reporting progress and
73/// honouring a [`CancellationToken`].
74pub struct StepExecutor<T> {
75    steps: Vec<Step<T>>,
76}
77
78impl<T: Send + 'static> StepExecutor<T> {
79    /// Create a new executor with the given steps.
80    pub fn new(steps: Vec<Step<T>>) -> Self {
81        Self { steps }
82    }
83
84    /// Run all steps sequentially.
85    ///
86    /// * Each step receives the output of the previous step (or `input` for the first).
87    /// * `on_progress` is called with [`StepStatus::Started`] and [`StepStatus::Completed`]
88    ///   (or [`StepStatus::Failed`]) for every step.
89    /// * If `cancel` is triggered, the executor stops between steps and returns
90    ///   [`ErrorCode::Cancelled`].
91    pub async fn execute(
92        &self,
93        input: T,
94        on_progress: impl Fn(StepStatus) + Send,
95        cancel: CancellationToken,
96    ) -> AppResult<T> {
97        let mut current = input;
98
99        for step in &self.steps {
100            // Check cancellation between steps
101            if cancel.is_cancelled() {
102                return Err(AppError::new(ErrorCode::Cancelled, "pipeline cancelled"));
103            }
104
105            on_progress(StepStatus::Started {
106                step_id: step.id.clone(),
107            });
108
109            let start = Instant::now();
110            let fut = (step.execute)(current);
111
112            // Race the step future against cancellation
113            let result = tokio::select! {
114                biased;
115                _ = cancel.cancelled() => {
116                    let elapsed = start.elapsed();
117                    on_progress(StepStatus::Failed {
118                        step_id: step.id.clone(),
119                        error: "cancelled".into(),
120                        elapsed,
121                    });
122                    return Err(AppError::new(ErrorCode::Cancelled, "pipeline cancelled"));
123                }
124                r = fut => r,
125            };
126
127            let elapsed = start.elapsed();
128
129            match result {
130                Ok(value) => {
131                    on_progress(StepStatus::Completed {
132                        step_id: step.id.clone(),
133                        elapsed,
134                    });
135                    current = value;
136                }
137                Err(e) => {
138                    on_progress(StepStatus::Failed {
139                        step_id: step.id.clone(),
140                        error: e.to_string(),
141                        elapsed,
142                    });
143                    return Err(e);
144                }
145            }
146        }
147
148        Ok(current)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn make_step(id: &str, transform: fn(i32) -> AppResult<i32>) -> Step<i32> {
157        let id_owned = id.to_string();
158        Step::new(
159            id,
160            id,
161            Box::new(move |val| {
162                let _id = id_owned.clone();
163                Box::pin(async move { transform(val) })
164            }),
165        )
166    }
167
168    #[tokio::test]
169    async fn executes_steps_sequentially() {
170        let steps = vec![
171            make_step("add-one", |v| Ok(v + 1)),
172            make_step("double", |v| Ok(v * 2)),
173        ];
174        let executor = StepExecutor::new(steps);
175        let statuses = std::sync::Arc::new(parking_lot::Mutex::new(Vec::new()));
176        let s = statuses.clone();
177
178        let result = executor
179            .execute(
180                5,
181                move |status| s.lock().push(status),
182                CancellationToken::new(),
183            )
184            .await
185            .unwrap();
186
187        // (5 + 1) * 2 = 12
188        assert_eq!(result, 12);
189
190        let statuses = statuses.lock();
191        assert_eq!(statuses.len(), 4); // Started + Completed for each step
192        assert!(matches!(&statuses[0], StepStatus::Started { step_id } if step_id == "add-one"));
193        assert!(
194            matches!(&statuses[1], StepStatus::Completed { step_id, .. } if step_id == "add-one")
195        );
196        assert!(matches!(&statuses[2], StepStatus::Started { step_id } if step_id == "double"));
197        assert!(
198            matches!(&statuses[3], StepStatus::Completed { step_id, .. } if step_id == "double")
199        );
200    }
201
202    #[tokio::test]
203    async fn propagates_step_failure() {
204        let steps = vec![
205            make_step("ok", |v| Ok(v + 1)),
206            make_step("fail", |_| {
207                Err(AppError::new(ErrorCode::Internal, "step broke"))
208            }),
209            make_step("unreachable", Ok),
210        ];
211        let executor = StepExecutor::new(steps);
212
213        let err = executor
214            .execute(0, |_| {}, CancellationToken::new())
215            .await
216            .unwrap_err();
217        assert_eq!(err.code(), ErrorCode::Internal);
218    }
219
220    #[tokio::test]
221    async fn cancellation_stops_execution() {
222        let token = CancellationToken::new();
223        let token_clone = token.clone();
224
225        let steps = vec![
226            make_step("first", |v| Ok(v + 1)),
227            make_step("second", |v| Ok(v + 1)),
228        ];
229        let executor = StepExecutor::new(steps);
230
231        // Cancel before execution
232        token_clone.cancel();
233
234        let err = executor.execute(0, |_| {}, token).await.unwrap_err();
235        assert_eq!(err.code(), ErrorCode::Cancelled);
236    }
237
238    #[tokio::test]
239    async fn empty_steps_returns_input() {
240        let executor = StepExecutor::<i32>::new(vec![]);
241        let result = executor
242            .execute(42, |_| {}, CancellationToken::new())
243            .await
244            .unwrap();
245        assert_eq!(result, 42);
246    }
247}