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::{DateTime, TimeDelta, Utc};
15use tokio::spawn;
16use tracing::{error, info, warn};
17use uuid::Uuid;
18
19use ironflow_store::error::StoreError;
20use ironflow_store::models::{Run, RunFilter, 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::guard::WorkflowRejection;
26use crate::notify::{Event, RunStatusChangedEvent};
27
28/// Error recorded on the steps left open by a cancelled run.
29///
30/// # Examples
31///
32/// ```
33/// use ironflow_engine::cancel::RUN_CANCELLED_ERROR;
34///
35/// assert_eq!(RUN_CANCELLED_ERROR, "run cancelled");
36/// ```
37pub const RUN_CANCELLED_ERROR: &str = "run cancelled";
38
39/// Outcome of [`Engine::cancel_run`].
40///
41/// # Examples
42///
43/// ```no_run
44/// use std::sync::Arc;
45/// use ironflow_engine::engine::Engine;
46/// use ironflow_engine::error::EngineError;
47/// use uuid::Uuid;
48///
49/// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
50/// let cancellation = engine.cancel_run(run_id).await?;
51/// println!(
52///     "run {} cancelled with {} sub-runs",
53///     cancellation.run.id,
54///     cancellation.cancelled_descendants.len()
55/// );
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Debug, Clone)]
60pub struct RunCancellation {
61    /// The cancelled run, as stored after the cancellation.
62    pub run: Run,
63    /// The sub-workflow runs below it that this call cancelled, oldest first.
64    pub cancelled_descendants: Vec<Uuid>,
65}
66
67impl Engine {
68    /// Cancel a run and every non-terminal sub-workflow run below it.
69    ///
70    /// The run moves to `Cancelled`, its open steps are closed with
71    /// [`RUN_CANCELLED_ERROR`] and [`Event::RunStatusChanged`] is published.
72    /// Its descendants are then cancelled by
73    /// [`cancel_descendants`](Self::cancel_descendants), which releases their
74    /// concurrency keys.
75    ///
76    /// When the run is a child whose root waits suspended on it
77    /// (`AwaitingApproval` or `Sleeping`, its open `Workflow` step recording
78    /// the branch of this child, no gate of its own open), the root is woken so its open
79    /// `Workflow` step observes the cancellation: under
80    /// [`ExecutionMode::Local`] it resumes in a background task, under
81    /// [`ExecutionMode::Workers`] it goes back to `Pending` for a worker. The
82    /// parent then fails, unless its step tolerates the failure with
83    /// `allow_failure`.
84    ///
85    /// Cancelling a run already `Cancelled` is accepted: it publishes nothing
86    /// for the run itself, does not wake its root either, and cancels
87    /// whatever descendant is still active.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`EngineError::Store`] with [`StoreError::RunNotFound`] for an
92    /// unknown run, with [`StoreError::InvalidTransition`] for a run that
93    /// already finished otherwise (`Completed`, `Failed`, `Warning`), and with
94    /// the store error when the cancellation cannot be persisted.
95    ///
96    /// # Examples
97    ///
98    /// ```no_run
99    /// use std::sync::Arc;
100    /// use ironflow_engine::engine::Engine;
101    /// use ironflow_engine::error::EngineError;
102    /// use uuid::Uuid;
103    ///
104    /// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
105    /// let cancellation = engine.cancel_run(run_id).await?;
106    /// assert!(cancellation.run.status.state.is_terminal());
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub async fn cancel_run(
111        self: &Arc<Self>,
112        run_id: Uuid,
113    ) -> Result<RunCancellation, EngineError> {
114        let run = self.load_run(run_id).await?;
115        let from = run.status.state;
116        if !from.can_transition_to(&RunStatus::Cancelled) {
117            return Err(EngineError::Store(StoreError::InvalidTransition {
118                from,
119                to: RunStatus::Cancelled,
120            }));
121        }
122
123        if from != RunStatus::Cancelled {
124            self.store()
125                .update_run(
126                    run_id,
127                    RunUpdate {
128                        status: Some(RunStatus::Cancelled),
129                        completed_at: Some(Utc::now()),
130                        ..RunUpdate::default()
131                    },
132                )
133                .await?;
134            self.publish_cancelled(&run, None);
135            info!(run_id = %run_id, from = %from, "run cancelled");
136        }
137        self.fail_orphaned_steps(run_id, RUN_CANCELLED_ERROR)
138            .await?;
139
140        let reason = format!("ancestor run {run_id} cancelled");
141        let cancelled_descendants = self.cancel_descendants(run_id, &reason).await?;
142
143        // A re-cancel changes nothing the root could observe: waking it again
144        // would replay a root that waits on something else.
145        if from != RunStatus::Cancelled {
146            self.wake_root_of_cancelled_child(&run).await;
147        }
148
149        Ok(RunCancellation {
150            run: self.load_run(run_id).await?,
151            cancelled_descendants,
152        })
153    }
154
155    /// Cancel every non-terminal sub-workflow run below `run_id`, at any depth.
156    ///
157    /// Each descendant moves to `Cancelled` with `reason` as its error, its
158    /// open steps are closed with the same reason, and
159    /// [`Event::RunStatusChanged`] is published for it. A terminal descendant
160    /// releases its concurrency key. A descendant that finished on its own in
161    /// the meantime is left as it is. Returns the ids of the runs cancelled,
162    /// oldest first: an empty list when nothing was left to cancel.
163    ///
164    /// Called when a run stops for good or ends an attempt
165    /// ([`fail_or_schedule_retry`](Self::fail_or_schedule_retry)), when the
166    /// reaper fails a run whose worker died, and by
167    /// [`cancel_run`](Self::cancel_run).
168    ///
169    /// # Errors
170    ///
171    /// Returns [`EngineError::Store`] when the descendants cannot be listed or
172    /// one of them cannot be updated.
173    ///
174    /// # Examples
175    ///
176    /// ```no_run
177    /// use ironflow_engine::engine::Engine;
178    /// use ironflow_engine::error::EngineError;
179    /// use uuid::Uuid;
180    ///
181    /// # async fn example(engine: &Engine, run_id: Uuid) -> Result<(), EngineError> {
182    /// let cancelled = engine
183    ///     .cancel_descendants(run_id, "parent run stopped")
184    ///     .await?;
185    /// println!("{} sub-runs cancelled", cancelled.len());
186    /// # Ok(())
187    /// # }
188    /// ```
189    pub async fn cancel_descendants(
190        &self,
191        run_id: Uuid,
192        reason: &str,
193    ) -> Result<Vec<Uuid>, EngineError> {
194        let descendants = self.store().list_active_descendants(run_id).await?;
195        let mut cancelled = Vec::with_capacity(descendants.len());
196
197        for descendant in descendants {
198            let update = RunUpdate {
199                status: Some(RunStatus::Cancelled),
200                error: Some(reason.to_string()),
201                completed_at: Some(Utc::now()),
202                ..RunUpdate::default()
203            };
204            match self.store().update_run(descendant.id, update).await {
205                Ok(()) => {}
206                // Finished on its own since it was listed: nothing to cancel.
207                Err(StoreError::InvalidTransition { from, .. }) => {
208                    info!(run_id = %descendant.id, status = %from, "descendant already finished");
209                    continue;
210                }
211                Err(err) => return Err(err.into()),
212            }
213            self.fail_orphaned_steps(descendant.id, reason).await?;
214            self.publish_cancelled(&descendant, Some(reason));
215            cancelled.push(descendant.id);
216        }
217
218        if !cancelled.is_empty() {
219            info!(
220                run_id = %run_id,
221                count = cancelled.len(),
222                reason = %reason,
223                "descendant runs cancelled"
224            );
225        }
226        Ok(cancelled)
227    }
228
229    /// Cancel the root runs left `Sleeping` or `AwaitingApproval` past the
230    /// [`run_deadline`](crate::guard::WorkflowGuardConfig::run_deadline) of
231    /// their workflow, at most `limit` of them.
232    ///
233    /// A running run is stopped in process by the guard at its deadline; a
234    /// run that sleeps (waiting for an approval, a signal, a human input or
235    /// a timer) executes nothing, so this sweep stops it instead. Each run
236    /// moves to `Cancelled` with a `run deadline reached` error, its open
237    /// gate or signal step is closed and its descendants are cancelled.
238    /// Child runs are never stopped on their own: cancelling their root
239    /// cancels them.
240    ///
241    /// A failure on one run is logged and skipped. Returns the ids of the
242    /// runs stopped.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`EngineError::Store`] when the candidate runs cannot be
247    /// listed.
248    ///
249    /// # Examples
250    ///
251    /// ```no_run
252    /// use ironflow_engine::engine::Engine;
253    /// use ironflow_engine::error::EngineError;
254    ///
255    /// # async fn example(engine: &Engine) -> Result<(), EngineError> {
256    /// let stopped = engine.cancel_runs_past_deadline(50).await?;
257    /// println!("{} runs stopped at their deadline", stopped.len());
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub async fn cancel_runs_past_deadline(&self, limit: u32) -> Result<Vec<Uuid>, EngineError> {
262        let mut stopped = Vec::new();
263        let now = Utc::now();
264        for name in self.handler_names() {
265            let Some(handler) = self.get_handler(name) else {
266                continue;
267            };
268            let Some(config) = self.effective_guard_config(handler.as_ref()) else {
269                continue;
270            };
271            let Some(deadline) = config.run_deadline else {
272                continue;
273            };
274            // A deadline too far to subtract from now cannot be reached yet.
275            let delta = TimeDelta::from_std(deadline).ok();
276            let Some(cutoff) = delta.and_then(|d| now.checked_sub_signed(d)) else {
277                continue;
278            };
279
280            for status in [RunStatus::Sleeping, RunStatus::AwaitingApproval] {
281                if stopped.len() >= limit as usize {
282                    return Ok(stopped);
283                }
284                // Every run is read before any is stopped: a stopped run
285                // leaves the filter. Child runs are skipped, they are
286                // stopped through their root.
287                let wanted = limit as usize - stopped.len();
288                let filter = RunFilter {
289                    workflow_name: Some(name.to_string()),
290                    status: Some(status),
291                    created_before: Some(cutoff),
292                    ..RunFilter::default()
293                };
294                let mut due = Vec::new();
295                for run in self.list_all_runs(filter).await? {
296                    if chain_root(&run).is_some() {
297                        continue;
298                    }
299                    let deadline_at = config.deadline_for(run.created_at);
300                    if let Some(deadline_at) = deadline_at.filter(|at| *at <= now) {
301                        due.push((run, deadline_at));
302                    }
303                }
304                due.truncate(wanted);
305
306                for (run, deadline_at) in due {
307                    match self.stop_run_at_deadline(&run, deadline_at).await {
308                        Ok(()) => stopped.push(run.id),
309                        Err(err) => {
310                            error!(
311                                run_id = %run.id,
312                                error = %err,
313                                "failed to stop a run at its deadline"
314                            );
315                        }
316                    }
317                }
318            }
319        }
320        Ok(stopped)
321    }
322
323    /// Cancel `run`, which reached its deadline at `deadline_at`, with its
324    /// open steps and its descendants.
325    async fn stop_run_at_deadline(
326        &self,
327        run: &Run,
328        deadline_at: DateTime<Utc>,
329    ) -> Result<(), EngineError> {
330        let rejection = WorkflowRejection::RunDeadlineReached { deadline_at };
331        let error = EngineError::from(rejection).to_string();
332        self.store()
333            .update_run(
334                run.id,
335                RunUpdate {
336                    status: Some(RunStatus::Cancelled),
337                    error: Some(error.clone()),
338                    completed_at: Some(Utc::now()),
339                    ..RunUpdate::default()
340                },
341            )
342            .await?;
343        self.publish_cancelled(run, Some(&error));
344        self.fail_orphaned_steps(run.id, &error).await?;
345        self.cancel_descendants(run.id, &error).await?;
346        info!(run_id = %run.id, deadline_at = %deadline_at, "run stopped at its deadline");
347        Ok(())
348    }
349
350    /// Close the descendants of a run whose attempt just ended, logging any
351    /// failure instead of returning it: the run's own status is already
352    /// persisted and must not be reported as unsaved.
353    pub(crate) async fn cancel_descendants_of_stopped_run(&self, run_id: Uuid, error: &str) {
354        let reason = format!("parent run {run_id} stopped: {error}");
355        if let Err(err) = self.cancel_descendants(run_id, &reason).await {
356            error!(run_id = %run_id, error = %err, "failed to cancel the descendants of a stopped run");
357        }
358    }
359
360    /// Wake the root of `run`'s chain, only when it is suspended on this child.
361    ///
362    /// A root suspended on something else (its own approval gate or human
363    /// input, a delay, another child) is left alone: waking it would replay
364    /// that suspension as if it had been resolved.
365    ///
366    /// Best effort: the cancellation is already persisted, so a failure is
367    /// logged and the root is left for an operator.
368    async fn wake_root_of_cancelled_child(self: &Arc<Self>, run: &Run) {
369        let Some(root_id) = chain_root(run) else {
370            return;
371        };
372        let root = match self.store().get_run(root_id).await {
373            Ok(Some(root)) => root,
374            Ok(None) => return,
375            Err(err) => {
376                warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot read the root of a cancelled child");
377                return;
378            }
379        };
380        if !self.root_awaits_child(&root, run).await {
381            return;
382        }
383
384        // A paused root is not woken: it resumes as an active run, so its
385        // replay observes the cancelled child once an operator resumes it.
386        if root.status.state == RunStatus::Paused {
387            if let Err(err) = self.requeue_paused_root(run).await {
388                warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot requeue the paused root of a cancelled child");
389            }
390            return;
391        }
392
393        let woken = match (root.status.state, self.execution_mode()) {
394            (RunStatus::AwaitingApproval | RunStatus::Sleeping, ExecutionMode::Workers) => {
395                self.store()
396                    .update_run_status(root_id, RunStatus::Pending)
397                    .await
398            }
399            (RunStatus::AwaitingApproval, ExecutionMode::Local) => {
400                self.store()
401                    .update_run_status(root_id, RunStatus::Running)
402                    .await
403            }
404            (RunStatus::Sleeping, ExecutionMode::Local) => {
405                match self
406                    .store()
407                    .update_run_status(root_id, RunStatus::Pending)
408                    .await
409                {
410                    Ok(()) => {
411                        self.store()
412                            .update_run_status(root_id, RunStatus::Running)
413                            .await
414                    }
415                    Err(err) => Err(err),
416                }
417            }
418            // Running (the child runs inline), queued or finished: nothing waits.
419            _ => return,
420        };
421        if let Err(err) = woken {
422            warn!(run_id = %run.id, root_run_id = %root_id, error = %err, "cannot wake the root of a cancelled child");
423            return;
424        }
425
426        info!(run_id = %run.id, root_run_id = %root_id, "root run woken to observe its cancelled child");
427        if self.execution_mode() == ExecutionMode::Local {
428            let engine = Arc::clone(self);
429            spawn(async move {
430                if let Err(err) = engine.resume_run(root_id).await {
431                    error!(root_run_id = %root_id, error = %err, "root run stopped after its child was cancelled");
432                }
433            });
434        }
435    }
436
437    /// Whether `root` is suspended on the branch of its chain that holds `run`.
438    ///
439    /// True only when the root waits (`AwaitingApproval`, `Sleeping`, or
440    /// `Paused` from one of them), none of its own steps is an open gate, and
441    /// its open `Workflow` step records the child run under which `run` sits.
442    /// A store error or a broken chain gives false: the wake is best effort.
443    async fn root_awaits_child(&self, root: &Run, run: &Run) -> bool {
444        let suspended = match root.status.state {
445            RunStatus::AwaitingApproval | RunStatus::Sleeping => true,
446            RunStatus::Paused => matches!(
447                root.resume_status,
448                Some(RunStatus::AwaitingApproval | RunStatus::Sleeping)
449            ),
450            _ => false,
451        };
452        if !suspended {
453            return false;
454        }
455
456        // Labels are data: a chain that loops back on itself stops there.
457        let mut visited = HashSet::from([run.id]);
458        let mut top_child = run.clone();
459        loop {
460            let Some(parent_id) = chain_parent(&top_child) else {
461                return false;
462            };
463            if parent_id == root.id {
464                break;
465            }
466            if !visited.insert(parent_id) {
467                return false;
468            }
469            top_child = match self.store().get_run(parent_id).await {
470                Ok(Some(parent)) => parent,
471                Ok(None) => return false,
472                Err(err) => {
473                    warn!(run_id = %run.id, ancestor_run_id = %parent_id, error = %err, "cannot read an ancestor of a cancelled child");
474                    return false;
475                }
476            };
477        }
478
479        let steps = match self.store().list_steps(root.id).await {
480            Ok(steps) => steps,
481            Err(err) => {
482                warn!(run_id = %run.id, root_run_id = %root.id, error = %err, "cannot read the steps of the root of a cancelled child");
483                return false;
484            }
485        };
486        // The root's own gate or human input is what suspends it.
487        if steps
488            .iter()
489            .any(|step| step.status.state == StepStatus::AwaitingApproval)
490        {
491            return false;
492        }
493        steps.iter().any(|step| {
494            step.kind == StepKind::Workflow
495                && step.status.state == StepStatus::Running
496                && recorded_child_run_id(step) == Some(top_child.id)
497        })
498    }
499
500    /// Publish the move of `run` (as loaded before the update) to `Cancelled`.
501    fn publish_cancelled(&self, run: &Run, error: Option<&str>) {
502        self.event_publisher()
503            .publish(Event::RunStatusChanged(RunStatusChangedEvent {
504                run_id: run.id,
505                workflow_name: run.workflow_name.clone(),
506                from: run.status.state,
507                to: RunStatus::Cancelled,
508                error: error.map(str::to_string),
509                cost_usd: run.cost_usd,
510                duration_ms: run.duration_ms,
511                labels: run.labels.clone(),
512                at: Utc::now(),
513            }));
514    }
515
516    pub(crate) async fn load_run(&self, run_id: Uuid) -> Result<Run, EngineError> {
517        self.store()
518            .get_run(run_id)
519            .await?
520            .ok_or(EngineError::Store(StoreError::RunNotFound(run_id)))
521    }
522}