Skip to main content

ironflow_engine/
cancel.rs

1//! Cancellation of a run and of the sub-workflow runs below it.
2//!
3//! A child run executes inside its parent's execution: once the parent stops
4//! (cancelled, failed, retried, abandoned by its worker), nothing drives the
5//! child any more. [`Engine::cancel_descendants`] closes those children so
6//! none stays non-terminal forever, holding its concurrency key.
7//! [`Engine::cancel_run`] cancels a run with its descendants, and wakes the
8//! root of a suspended chain whose child it cancelled, only when the root is
9//! suspended on that child, so the root observes it.
10
11use std::collections::HashSet;
12use std::sync::Arc;
13
14use chrono::Utc;
15use tokio::spawn;
16use tracing::{error, info, warn};
17use uuid::Uuid;
18
19use ironflow_store::error::StoreError;
20use ironflow_store::models::{Run, RunStatus, RunUpdate, StepKind, StepStatus};
21
22use crate::context::recorded_child_run_id;
23use crate::engine::{Engine, ExecutionMode, chain_parent, chain_root};
24use crate::error::EngineError;
25use crate::notify::{Event, RunStatusChangedEvent};
26
27/// Error recorded on the steps left open by a cancelled run.
28///
29/// # Examples
30///
31/// ```
32/// use ironflow_engine::cancel::RUN_CANCELLED_ERROR;
33///
34/// assert_eq!(RUN_CANCELLED_ERROR, "run cancelled");
35/// ```
36pub const RUN_CANCELLED_ERROR: &str = "run cancelled";
37
38/// Outcome of [`Engine::cancel_run`].
39///
40/// # Examples
41///
42/// ```no_run
43/// use std::sync::Arc;
44/// use ironflow_engine::engine::Engine;
45/// use ironflow_engine::error::EngineError;
46/// use uuid::Uuid;
47///
48/// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
49/// let cancellation = engine.cancel_run(run_id).await?;
50/// println!(
51///     "run {} cancelled with {} sub-runs",
52///     cancellation.run.id,
53///     cancellation.cancelled_descendants.len()
54/// );
55/// # Ok(())
56/// # }
57/// ```
58#[derive(Debug, Clone)]
59pub struct RunCancellation {
60    /// The cancelled run, as stored after the cancellation.
61    pub run: Run,
62    /// The sub-workflow runs below it that this call cancelled, oldest first.
63    pub cancelled_descendants: Vec<Uuid>,
64}
65
66impl Engine {
67    /// Cancel a run and every non-terminal sub-workflow run below it.
68    ///
69    /// The run moves to `Cancelled`, its open steps are closed with
70    /// [`RUN_CANCELLED_ERROR`] and [`Event::RunStatusChanged`] is published.
71    /// Its descendants are then cancelled by
72    /// [`cancel_descendants`](Self::cancel_descendants), which releases their
73    /// concurrency keys.
74    ///
75    /// When the run is a child whose root waits suspended on it
76    /// (`AwaitingApproval` or `Sleeping`, its open `Workflow` step recording
77    /// the branch of this child, no gate of its own open), the root is woken so its open
78    /// `Workflow` step observes the cancellation: under
79    /// [`ExecutionMode::Local`] it resumes in a background task, under
80    /// [`ExecutionMode::Workers`] it goes back to `Pending` for a worker. The
81    /// parent then fails, unless its step tolerates the failure with
82    /// `allow_failure`.
83    ///
84    /// Cancelling a run already `Cancelled` is accepted: it publishes nothing
85    /// for the run itself, does not wake its root either, and cancels
86    /// whatever descendant is still active.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`EngineError::Store`] with [`StoreError::RunNotFound`] for an
91    /// unknown run, with [`StoreError::InvalidTransition`] for a run that
92    /// already finished otherwise (`Completed`, `Failed`, `Warning`), and with
93    /// the store error when the cancellation cannot be persisted.
94    ///
95    /// # Examples
96    ///
97    /// ```no_run
98    /// use std::sync::Arc;
99    /// use ironflow_engine::engine::Engine;
100    /// use ironflow_engine::error::EngineError;
101    /// use uuid::Uuid;
102    ///
103    /// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
104    /// let cancellation = engine.cancel_run(run_id).await?;
105    /// assert!(cancellation.run.status.state.is_terminal());
106    /// # Ok(())
107    /// # }
108    /// ```
109    pub async fn cancel_run(
110        self: &Arc<Self>,
111        run_id: Uuid,
112    ) -> Result<RunCancellation, EngineError> {
113        let run = self.load_run(run_id).await?;
114        let from = run.status.state;
115        if !from.can_transition_to(&RunStatus::Cancelled) {
116            return Err(EngineError::Store(StoreError::InvalidTransition {
117                from,
118                to: RunStatus::Cancelled,
119            }));
120        }
121
122        if from != RunStatus::Cancelled {
123            self.store()
124                .update_run(
125                    run_id,
126                    RunUpdate {
127                        status: Some(RunStatus::Cancelled),
128                        completed_at: Some(Utc::now()),
129                        ..RunUpdate::default()
130                    },
131                )
132                .await?;
133            self.publish_cancelled(&run, None);
134            info!(run_id = %run_id, from = %from, "run cancelled");
135        }
136        self.fail_orphaned_steps(run_id, RUN_CANCELLED_ERROR)
137            .await?;
138
139        let reason = format!("ancestor run {run_id} cancelled");
140        let cancelled_descendants = self.cancel_descendants(run_id, &reason).await?;
141
142        // A re-cancel changes nothing the root could observe: waking it again
143        // would replay a root that waits on something else.
144        if from != RunStatus::Cancelled {
145            self.wake_root_of_cancelled_child(&run).await;
146        }
147
148        Ok(RunCancellation {
149            run: self.load_run(run_id).await?,
150            cancelled_descendants,
151        })
152    }
153
154    /// Cancel every non-terminal sub-workflow run below `run_id`, at any depth.
155    ///
156    /// Each descendant moves to `Cancelled` with `reason` as its error, its
157    /// open steps are closed with the same reason, and
158    /// [`Event::RunStatusChanged`] is published for it. A terminal descendant
159    /// releases its concurrency key. A descendant that finished on its own in
160    /// the meantime is left as it is. Returns the ids of the runs cancelled,
161    /// oldest first: an empty list when nothing was left to cancel.
162    ///
163    /// Called when a run stops for good or ends an attempt
164    /// ([`fail_or_schedule_retry`](Self::fail_or_schedule_retry)), when the
165    /// reaper fails a run whose worker died, and by
166    /// [`cancel_run`](Self::cancel_run).
167    ///
168    /// # Errors
169    ///
170    /// Returns [`EngineError::Store`] when the descendants cannot be listed or
171    /// one of them cannot be updated.
172    ///
173    /// # Examples
174    ///
175    /// ```no_run
176    /// use ironflow_engine::engine::Engine;
177    /// use ironflow_engine::error::EngineError;
178    /// use uuid::Uuid;
179    ///
180    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
181    /// let cancelled = engine
182    ///     .cancel_descendants(run_id, "parent run stopped")
183    ///     .await?;
184    /// println!("{} sub-runs cancelled", cancelled.len());
185    /// # Ok(())
186    /// # }
187    /// ```
188    pub async fn cancel_descendants(
189        &self,
190        run_id: Uuid,
191        reason: &str,
192    ) -> Result<Vec<Uuid>, EngineError> {
193        let descendants = self.store().list_active_descendants(run_id).await?;
194        let mut cancelled = Vec::with_capacity(descendants.len());
195
196        for descendant in descendants {
197            let update = RunUpdate {
198                status: Some(RunStatus::Cancelled),
199                error: Some(reason.to_string()),
200                completed_at: Some(Utc::now()),
201                ..RunUpdate::default()
202            };
203            match self.store().update_run(descendant.id, update).await {
204                Ok(()) => {}
205                // Finished on its own since it was listed: nothing to cancel.
206                Err(StoreError::InvalidTransition { from, .. }) => {
207                    info!(run_id = %descendant.id, status = %from, "descendant already finished");
208                    continue;
209                }
210                Err(err) => return Err(err.into()),
211            }
212            self.fail_orphaned_steps(descendant.id, reason).await?;
213            self.publish_cancelled(&descendant, Some(reason));
214            cancelled.push(descendant.id);
215        }
216
217        if !cancelled.is_empty() {
218            info!(
219                run_id = %run_id,
220                count = cancelled.len(),
221                reason = %reason,
222                "descendant runs cancelled"
223            );
224        }
225        Ok(cancelled)
226    }
227
228    /// Close the descendants of a run whose attempt just ended, logging any
229    /// failure instead of returning it: the run's own status is already
230    /// persisted and must not be reported as unsaved.
231    pub(crate) async fn cancel_descendants_of_stopped_run(&self, run_id: Uuid, error: &str) {
232        let reason = format!("parent run {run_id} stopped: {error}");
233        if let Err(err) = self.cancel_descendants(run_id, &reason).await {
234            error!(run_id = %run_id, error = %err, "failed to cancel the descendants of a stopped run");
235        }
236    }
237
238    /// Wake the root of `run`'s chain, only when it is suspended on this child.
239    ///
240    /// A root suspended on something else (its own approval gate or human
241    /// input, a delay, another child) is left alone: waking it would replay
242    /// that suspension as if it had been resolved.
243    ///
244    /// Best effort: the cancellation is already persisted, so a failure is
245    /// logged and the root is left for an operator.
246    async fn wake_root_of_cancelled_child(self: &Arc<Self>, run: &Run) {
247        let Some(root_id) = chain_root(run) else {
248            return;
249        };
250        let root = match self.store().get_run(root_id).await {
251            Ok(Some(root)) => root,
252            Ok(None) => return,
253            Err(err) => {
254                warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot read the root of a cancelled child");
255                return;
256            }
257        };
258        if !self.root_awaits_child(&root, run).await {
259            return;
260        }
261
262        // A paused root is not woken: it resumes as an active run, so its
263        // replay observes the cancelled child once an operator resumes it.
264        if root.status.state == RunStatus::Paused {
265            if let Err(err) = self.requeue_paused_root(run).await {
266                warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot requeue the paused root of a cancelled child");
267            }
268            return;
269        }
270
271        let woken = match (root.status.state, self.execution_mode()) {
272            (RunStatus::AwaitingApproval | RunStatus::Sleeping, ExecutionMode::Workers) => {
273                self.store()
274                    .update_run_status(root_id, RunStatus::Pending)
275                    .await
276            }
277            (RunStatus::AwaitingApproval, ExecutionMode::Local) => {
278                self.store()
279                    .update_run_status(root_id, RunStatus::Running)
280                    .await
281            }
282            (RunStatus::Sleeping, ExecutionMode::Local) => {
283                match self
284                    .store()
285                    .update_run_status(root_id, RunStatus::Pending)
286                    .await
287                {
288                    Ok(()) => {
289                        self.store()
290                            .update_run_status(root_id, RunStatus::Running)
291                            .await
292                    }
293                    Err(err) => Err(err),
294                }
295            }
296            // Running (the child runs inline), queued or finished: nothing waits.
297            _ => return,
298        };
299        if let Err(err) = woken {
300            warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot wake the root of a cancelled child");
301            return;
302        }
303
304        info!(run_id = %run.id, root_run_id = %root_id, "root run woken to observe its cancelled child");
305        if self.execution_mode() == ExecutionMode::Local {
306            let engine = Arc::clone(self);
307            spawn(async move {
308                if let Err(err) = engine.resume_run(root_id).await {
309                    error!(root_run_id = %root_id, error = %err, "root run stopped after its child was cancelled");
310                }
311            });
312        }
313    }
314
315    /// Whether `root` is suspended on the branch of its chain that holds `run`.
316    ///
317    /// True only when the root waits (`AwaitingApproval`, `Sleeping`, or
318    /// `Paused` from one of them), none of its own steps is an open gate, and
319    /// its open `Workflow` step records the child run under which `run` sits.
320    /// A store error or a broken chain gives false: the wake is best effort.
321    async fn root_awaits_child(&self, root: &Run, run: &Run) -> bool {
322        let suspended = match root.status.state {
323            RunStatus::AwaitingApproval | RunStatus::Sleeping => true,
324            RunStatus::Paused => matches!(
325                root.resume_status,
326                Some(RunStatus::AwaitingApproval | RunStatus::Sleeping)
327            ),
328            _ => false,
329        };
330        if !suspended {
331            return false;
332        }
333
334        // Labels are data: a chain that loops back on itself stops there.
335        let mut visited = HashSet::from([run.id]);
336        let mut top_child = run.clone();
337        loop {
338            let Some(parent_id) = chain_parent(&top_child) else {
339                return false;
340            };
341            if parent_id == root.id {
342                break;
343            }
344            if !visited.insert(parent_id) {
345                return false;
346            }
347            top_child = match self.store().get_run(parent_id).await {
348                Ok(Some(parent)) => parent,
349                Ok(None) => return false,
350                Err(err) => {
351                    warn!(run_id = %run.id, ancestor_run_id = %parent_id, error = %err, "cannot read an ancestor of a cancelled child");
352                    return false;
353                }
354            };
355        }
356
357        let steps = match self.store().list_steps(root.id).await {
358            Ok(steps) => steps,
359            Err(err) => {
360                warn!(run_id = %run.id, root_run_id = %root.id, error = %err, "cannot read the steps of the root of a cancelled child");
361                return false;
362            }
363        };
364        // The root's own gate or human input is what suspends it.
365        if steps
366            .iter()
367            .any(|step| step.status.state == StepStatus::AwaitingApproval)
368        {
369            return false;
370        }
371        steps.iter().any(|step| {
372            step.kind == StepKind::Workflow
373                && step.status.state == StepStatus::Running
374                && recorded_child_run_id(step) == Some(top_child.id)
375        })
376    }
377
378    /// Publish the move of `run` (as loaded before the update) to `Cancelled`.
379    fn publish_cancelled(&self, run: &Run, error: Option<&str>) {
380        self.event_publisher()
381            .publish(Event::RunStatusChanged(RunStatusChangedEvent {
382                run_id: run.id,
383                workflow_name: run.workflow_name.clone(),
384                from: run.status.state,
385                to: RunStatus::Cancelled,
386                error: error.map(str::to_string),
387                cost_usd: run.cost_usd,
388                duration_ms: run.duration_ms,
389                labels: run.labels.clone(),
390                at: Utc::now(),
391            }));
392    }
393
394    pub(crate) async fn load_run(&self, run_id: Uuid) -> Result<Run, EngineError> {
395        self.store()
396            .get_run(run_id)
397            .await?
398            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))
399    }
400}