Skip to main content

ironflow_engine/
pause.rs

1//! Operator pause of a run, or of a whole workflow.
2//!
3//! [`Engine::pause_run`] holds a root run, with every sub-workflow run below
4//! it, in `Paused` until [`Engine::resume_paused_run`] puts each one back in
5//! the state it was paused from, or [`Engine::cancel_run`] stops it. A run
6//! executing when it is paused has its step in flight interrupted with
7//! [`STEP_INTERRUPTED_ERROR`](ironflow_store::store::STEP_INTERRUPTED_ERROR),
8//! no further step is started, and the resume replays the run: finished steps
9//! are skipped and the interrupted step is executed again.
10//!
11//! [`Engine::pause_workflow`] holds the queued runs of a workflow instead:
12//! they are created as usual but no worker picks them up until
13//! [`Engine::resume_workflow`]. Under [`ExecutionMode::Local`] no worker
14//! exists: a run created while the workflow is paused stays `Pending`, and
15//! [`Engine::resume_workflow`] starts it in a background task.
16
17use std::sync::Arc;
18
19use chrono::Utc;
20use tracing::{debug, info};
21use uuid::Uuid;
22
23use ironflow_store::error::StoreError;
24use ironflow_store::models::{Run, RunFilter, RunStatus, RunUpdate, WorkflowPause};
25
26use crate::engine::{Engine, ExecutionMode, chain_root};
27use crate::error::EngineError;
28use crate::notify::{Event, RunStatusChangedEvent, WorkflowPausedEvent, WorkflowResumedEvent};
29
30/// Outcome of [`Engine::pause_run`].
31///
32/// # Examples
33///
34/// ```no_run
35/// use std::sync::Arc;
36/// use ironflow_engine::engine::Engine;
37/// use ironflow_engine::error::EngineError;
38/// use uuid::Uuid;
39///
40/// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
41/// let pause = engine.pause_run(run_id).await?;
42/// println!(
43///     "run {} paused with {} sub-runs",
44///     pause.run.id,
45///     pause.paused_descendants.len()
46/// );
47/// # Ok(())
48/// # }
49/// ```
50#[derive(Debug, Clone)]
51pub struct RunPause {
52    /// The paused run, as stored after the pause.
53    pub run: Run,
54    /// The sub-workflow runs below it that this call paused, oldest first.
55    pub paused_descendants: Vec<Uuid>,
56}
57
58/// Outcome of [`Engine::resume_paused_run`].
59///
60/// # Examples
61///
62/// ```no_run
63/// use std::sync::Arc;
64/// use ironflow_engine::engine::Engine;
65/// use ironflow_engine::error::EngineError;
66/// use uuid::Uuid;
67///
68/// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
69/// let resume = engine.resume_paused_run(run_id).await?;
70/// println!("run {} is {}", resume.run.id, resume.run.status.state);
71/// # Ok(())
72/// # }
73/// ```
74#[derive(Debug, Clone)]
75pub struct RunResume {
76    /// The resumed run, as stored after the resume.
77    pub run: Run,
78    /// The sub-workflow runs below it that this call resumed, oldest first.
79    pub resumed_descendants: Vec<Uuid>,
80}
81
82impl Engine {
83    /// Pause a root run and every active sub-workflow run below it.
84    ///
85    /// Each run moves to `Paused` and records the state it was paused from in
86    /// [`Run::resume_status`]. A run that was executing has its running steps
87    /// marked `Failed` with
88    /// [`STEP_INTERRUPTED_ERROR`](ironflow_store::store::STEP_INTERRUPTED_ERROR),
89    /// so the resume executes them again. A run held by a worker loses its lease: the worker drops
90    /// the execution at its next renewal, and the reaper never touches a
91    /// paused run. A run executing in-process stops before its next step.
92    /// [`Event::RunStatusChanged`] is published for every run paused.
93    ///
94    /// While the run is paused, an approval, a human input or a signal it
95    /// waits for can still be resolved: the decision is recorded and only
96    /// changes the state the run resumes to. The SLA deadline of an approval
97    /// gate keeps running: the [`ApprovalEscalator`](crate::escalation::ApprovalEscalator)
98    /// applies its policy during the pause, with the same effect as a human
99    /// decision.
100    ///
101    /// # Errors
102    ///
103    /// - [`EngineError::Store`] with [`StoreError::RunNotFound`] for an
104    ///   unknown run.
105    /// - [`EngineError::ChildRunNotPausable`] for a sub-workflow run: pause
106    ///   its root run instead.
107    /// - [`EngineError::Store`] with [`StoreError::InvalidTransition`] for a
108    ///   run already paused or finished.
109    /// - [`EngineError::Store`] when the pause cannot be persisted.
110    ///
111    /// # Examples
112    ///
113    /// ```no_run
114    /// use std::sync::Arc;
115    /// use ironflow_engine::engine::Engine;
116    /// use ironflow_engine::error::EngineError;
117    /// use ironflow_store::models::RunStatus;
118    /// use uuid::Uuid;
119    ///
120    /// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
121    /// let pause = engine.pause_run(run_id).await?;
122    /// assert_eq!(pause.run.status.state, RunStatus::Paused);
123    /// # Ok(())
124    /// # }
125    /// ```
126    pub async fn pause_run(&self, run_id: Uuid) -> Result<RunPause, EngineError> {
127        let run = self.load_pausable_root(run_id).await?;
128        let from = run.status.state;
129        if !from.can_transition_to(&RunStatus::Paused) {
130            return Err(EngineError::Store(StoreError::InvalidTransition {
131                from,
132                to: RunStatus::Paused,
133            }));
134        }
135
136        // The root first: an execution in flight checks its own run before
137        // every step, so it stops as soon as possible.
138        self.store()
139            .update_run_status(run_id, RunStatus::Paused)
140            .await?;
141        if from == RunStatus::Running {
142            self.interrupt_running_steps(run_id).await?;
143        }
144        self.publish_transition(&run, RunStatus::Paused);
145        info!(run_id = %run_id, from = %from, "run paused");
146
147        let mut paused_descendants = Vec::new();
148        for descendant in self.store().list_active_descendants(run_id).await? {
149            if !descendant
150                .status
151                .state
152                .can_transition_to(&RunStatus::Paused)
153            {
154                continue;
155            }
156            match self
157                .store()
158                .update_run_status(descendant.id, RunStatus::Paused)
159                .await
160            {
161                Ok(()) => {}
162                // Finished or paused since it was listed: nothing to pause.
163                Err(StoreError::InvalidTransition { .. }) => {
164                    debug!(
165                        run_id = %descendant.id,
166                        "descendant run no longer pausable, skipped"
167                    );
168                    continue;
169                }
170                Err(err) => return Err(err.into()),
171            }
172            if descendant.status.state == RunStatus::Running {
173                self.interrupt_running_steps(descendant.id).await?;
174            }
175            self.publish_transition(&descendant, RunStatus::Paused);
176            paused_descendants.push(descendant.id);
177        }
178
179        if !paused_descendants.is_empty() {
180            info!(
181                run_id = %run_id,
182                count = paused_descendants.len(),
183                "descendant runs paused"
184            );
185        }
186
187        Ok(RunPause {
188            run: self.load_run(run_id).await?,
189            paused_descendants,
190        })
191    }
192
193    /// Resume a paused root run and the sub-workflow runs paused with it.
194    ///
195    /// Each run goes back to the state recorded in [`Run::resume_status`]:
196    /// a run paused while waiting (`Pending`, `Retrying`, `Sleeping`,
197    /// `AwaitingApproval`) waits again, and a run whose approval, human input
198    /// or signal was resolved during the pause is queued. A root that was
199    /// executing is queued with its interrupted steps, which are executed
200    /// again; finished steps are replayed. A sleeping root whose deadline
201    /// passed during the pause is queued at once; a sleeping sub-workflow run
202    /// in the same case is woken by the [`RunWaker`](crate::wake::RunWaker)
203    /// on its next tick.
204    ///
205    /// Under [`ExecutionMode::Local`] a queued root is resumed in a background
206    /// task, and so is a queued sub-workflow run whose root still waits.
207    /// [`Event::RunStatusChanged`] is published for every run resumed.
208    ///
209    /// Under [`ExecutionMode::Local`], an execution still inside its step when
210    /// the run is resumed is not doubled: the run goes back to `Running` and
211    /// that execution carries on, and the background task waits for it to end
212    /// before deciding whether anything is left to restart.
213    ///
214    /// # Errors
215    ///
216    /// - [`EngineError::Store`] with [`StoreError::RunNotFound`] for an
217    ///   unknown run.
218    /// - [`EngineError::ChildRunNotPausable`] for a sub-workflow run: resume
219    ///   its root run instead.
220    /// - [`EngineError::Store`] with [`StoreError::InvalidTransition`] for a
221    ///   run that is not paused.
222    /// - [`EngineError::Store`] when the resume cannot be persisted.
223    ///
224    /// # Examples
225    ///
226    /// ```no_run
227    /// use std::sync::Arc;
228    /// use ironflow_engine::engine::Engine;
229    /// use ironflow_engine::error::EngineError;
230    /// use ironflow_store::models::RunStatus;
231    /// use uuid::Uuid;
232    ///
233    /// # async fn example(engine: Arc<Engine>, run_id: Uuid) -> Result<(), EngineError> {
234    /// let resume = engine.resume_paused_run(run_id).await?;
235    /// assert_ne!(resume.run.status.state, RunStatus::Paused);
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub async fn resume_paused_run(
240        self: &Arc<Self>,
241        run_id: Uuid,
242    ) -> Result<RunResume, EngineError> {
243        let run = self.load_pausable_root(run_id).await?;
244        if run.status.state != RunStatus::Paused {
245            return Err(EngineError::Store(StoreError::InvalidTransition {
246                from: run.status.state,
247                to: RunStatus::Pending,
248            }));
249        }
250
251        // The descendants first: the root re-enters them as soon as it runs.
252        let mut resumed_descendants = Vec::new();
253        let mut queued_descendants = Vec::new();
254        for descendant in self.store().list_active_descendants(run_id).await? {
255            if descendant.status.state != RunStatus::Paused {
256                continue;
257            }
258            // A child that was executing stays `Running`: its root's replay
259            // re-enters it and executes its interrupted steps again.
260            let target = descendant.resume_status.unwrap_or(RunStatus::Pending);
261            self.store()
262                .update_run_status(descendant.id, target)
263                .await?;
264            self.publish_transition(&descendant, target);
265            if target == RunStatus::Pending {
266                queued_descendants.push(descendant.id);
267            }
268            resumed_descendants.push(descendant.id);
269        }
270
271        let target = match run.resume_status {
272            // Stopped by the pause in the middle of its execution: queued
273            // again, like a run whose worker lost its lease.
274            Some(RunStatus::Running) | None => {
275                self.interrupt_running_steps(run_id).await?;
276                RunStatus::Pending
277            }
278            // The deadline passed during the pause: due now, like a run the
279            // waker would have claimed. A past `scheduled_at` does not hold
280            // back the pick.
281            Some(RunStatus::Sleeping) if run.scheduled_at.is_some_and(|at| at <= Utc::now()) => {
282                RunStatus::Pending
283            }
284            Some(status) => status,
285        };
286        self.store().update_run_status(run_id, target).await?;
287        self.publish_transition(&run, target);
288        info!(run_id = %run_id, to = %target, "run resumed");
289
290        if self.execution_mode() == ExecutionMode::Local {
291            if target == RunStatus::Pending {
292                self.continue_in_flight_execution(run_id).await?;
293                self.spawn_local_resume(run_id);
294            } else {
295                // The root waits on its chain: the queued child resumes it.
296                for child_id in queued_descendants {
297                    self.continue_in_flight_execution(child_id).await?;
298                    self.spawn_local_resume(child_id);
299                }
300            }
301        }
302
303        Ok(RunResume {
304            run: self.load_run(run_id).await?,
305            resumed_descendants,
306        })
307    }
308
309    /// Hand a run just queued to `Pending` back to the execution still
310    /// running it in this process, if any.
311    ///
312    /// That execution does not see the pause at a step boundary once the run
313    /// left `Paused`, so the run must be `Running` for it to carry on as a
314    /// legitimate run.
315    async fn continue_in_flight_execution(&self, run_id: Uuid) -> Result<(), EngineError> {
316        if self.is_executing(run_id) {
317            self.store()
318                .update_run_status(run_id, RunStatus::Running)
319                .await?;
320            debug!(run_id = %run_id, "execution still in flight, run continues");
321        }
322        Ok(())
323    }
324
325    /// Pause a registered workflow: its queued runs are no longer picked up.
326    ///
327    /// Runs keep being created; workers skip them until
328    /// [`resume_workflow`](Self::resume_workflow). Under
329    /// [`ExecutionMode::Local`] a run created meanwhile stays `Pending` until
330    /// the resume. Runs already executing
331    /// are not affected: pause them with [`pause_run`](Self::pause_run).
332    /// Pausing a paused workflow returns the pause already recorded.
333    ///
334    /// Every call publishes [`Event::WorkflowPaused`] with `paused_by` as its
335    /// actor, a re-pause included: the request is a decision of its actor and
336    /// the store does not tell whether the pause is new.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`EngineError::InvalidWorkflow`] when no handler is registered
341    /// under `workflow_name`, and [`EngineError::Store`] when the pause cannot
342    /// be persisted.
343    ///
344    /// # Examples
345    ///
346    /// ```no_run
347    /// use ironflow_engine::engine::Engine;
348    /// use ironflow_engine::error::EngineError;
349    ///
350    /// # async fn example(engine: &Engine) -> Result<(), EngineError> {
351    /// let pause = engine.pause_workflow("deploy", None).await?;
352    /// println!("deploy paused at {}", pause.paused_at);
353    /// # Ok(())
354    /// # }
355    /// ```
356    pub async fn pause_workflow(
357        &self,
358        workflow_name: &str,
359        paused_by: Option<Uuid>,
360    ) -> Result<WorkflowPause, EngineError> {
361        self.require_handler(workflow_name)?;
362        let pause = self
363            .store()
364            .pause_workflow(workflow_name, paused_by)
365            .await?;
366        info!(workflow = %workflow_name, "workflow paused");
367        self.event_publisher()
368            .publish(Event::WorkflowPaused(WorkflowPausedEvent {
369                workflow_name: workflow_name.to_string(),
370                user_id: paused_by,
371                at: Utc::now(),
372            }));
373        Ok(pause)
374    }
375
376    /// Resume a paused workflow so its queued runs are picked up again.
377    ///
378    /// Returns `true` when the workflow was paused, `false` when it was not:
379    /// resuming is idempotent and starts nothing in that case.
380    ///
381    /// A resume of a paused workflow publishes [`Event::WorkflowResumed`]
382    /// with `resumed_by` as its actor; a resume of a workflow that is not
383    /// paused publishes nothing.
384    ///
385    /// Under [`ExecutionMode::Local`] the runs held `Pending` while the
386    /// workflow was paused are started in background tasks, except
387    /// sub-workflow runs and runs scheduled in the future. A held run is
388    /// executed in this process only once it is claimed atomically
389    /// (`Pending -> Running`); a run a worker claimed first is left to it.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`EngineError::InvalidWorkflow`] when no handler is registered
394    /// under `workflow_name`, and [`EngineError::Store`] when the resume
395    /// cannot be persisted or the held runs cannot be listed.
396    ///
397    /// # Examples
398    ///
399    /// ```no_run
400    /// use std::sync::Arc;
401    ///
402    /// use ironflow_engine::engine::Engine;
403    /// use ironflow_engine::error::EngineError;
404    ///
405    /// # async fn example(engine: Arc<Engine>) -> Result<(), EngineError> {
406    /// if engine.resume_workflow("deploy", None).await? {
407    ///     println!("deploy resumed");
408    /// }
409    /// # Ok(())
410    /// # }
411    /// ```
412    pub async fn resume_workflow(
413        self: &Arc<Self>,
414        workflow_name: &str,
415        resumed_by: Option<Uuid>,
416    ) -> Result<bool, EngineError> {
417        self.require_handler(workflow_name)?;
418        let was_paused = self.store().resume_workflow(workflow_name).await?;
419        if was_paused {
420            info!(workflow = %workflow_name, "workflow resumed");
421            self.event_publisher()
422                .publish(Event::WorkflowResumed(WorkflowResumedEvent {
423                    workflow_name: workflow_name.to_string(),
424                    user_id: resumed_by,
425                    at: Utc::now(),
426                }));
427            if self.execution_mode() == ExecutionMode::Local {
428                self.start_held_runs(workflow_name).await?;
429            }
430        }
431        Ok(was_paused)
432    }
433
434    /// Start, in background tasks, the due root runs of `workflow_name` left
435    /// `Pending` while it was paused.
436    ///
437    /// Each run goes through [`spawn_local_resume`](Self::spawn_local_resume):
438    /// it is executed here only if this process claims it atomically,
439    /// otherwise it is left to the worker that took it.
440    async fn start_held_runs(self: &Arc<Self>, workflow_name: &str) -> Result<(), EngineError> {
441        // Collect every run before spawning: a spawned run leaves `Pending`
442        // and would shift the pages (see `list_all_runs`).
443        let filter = RunFilter {
444            workflow_name: Some(workflow_name.to_string()),
445            status: Some(RunStatus::Pending),
446            ..RunFilter::default()
447        };
448        let held = self.list_all_runs(filter).await?;
449
450        let now = Utc::now();
451        for run in held {
452            if chain_root(&run).is_some() || run.scheduled_at.is_some_and(|at| at > now) {
453                continue;
454            }
455            self.spawn_local_resume(run.id);
456        }
457        Ok(())
458    }
459
460    /// Whether `workflow_name` is currently paused.
461    pub(crate) async fn is_workflow_paused(
462        &self,
463        workflow_name: &str,
464    ) -> Result<bool, EngineError> {
465        let pauses = self.store().list_workflow_pauses().await?;
466        Ok(pauses
467            .iter()
468            .any(|pause| pause.workflow_name == workflow_name))
469    }
470
471    fn require_handler(&self, workflow_name: &str) -> Result<(), EngineError> {
472        match self.get_handler(workflow_name) {
473            Some(_) => Ok(()),
474            None => Err(EngineError::InvalidWorkflow(format!(
475                "no handler registered for workflow '{workflow_name}'"
476            ))),
477        }
478    }
479
480    /// Make the paused root of `run`'s chain resume to `Pending` when it
481    /// waits suspended with its child, so that its replay observes what
482    /// happened to the child (cancelled, rejected) once an operator resumes
483    /// it.
484    pub(crate) async fn requeue_paused_root(&self, run: &Run) -> Result<(), EngineError> {
485        let Some(root_id) = chain_root(run) else {
486            return Ok(());
487        };
488        let root = self.load_run(root_id).await?;
489        let suspended = matches!(
490            root.resume_status,
491            Some(RunStatus::AwaitingApproval | RunStatus::Sleeping)
492        );
493        if root.status.state != RunStatus::Paused || !suspended {
494            return Ok(());
495        }
496
497        let update = RunUpdate {
498            resume_status: Some(RunStatus::Pending),
499            ..RunUpdate::default()
500        };
501        self.store().update_run(root_id, update).await?;
502        info!(run_id = %run.id, root_run_id = %root_id, "paused root run will resume to observe its child");
503        Ok(())
504    }
505
506    /// Load `run_id`, refusing a sub-workflow run.
507    async fn load_pausable_root(&self, run_id: Uuid) -> Result<Run, EngineError> {
508        let run = self.load_run(run_id).await?;
509        match chain_root(&run) {
510            Some(root_run_id) => Err(EngineError::ChildRunNotPausable {
511                run_id,
512                root_run_id,
513            }),
514            None => Ok(run),
515        }
516    }
517
518    /// Publish the move of `run` (as loaded before the update) to `to`.
519    fn publish_transition(&self, run: &Run, to: RunStatus) {
520        self.event_publisher()
521            .publish(Event::RunStatusChanged(RunStatusChangedEvent {
522                run_id: run.id,
523                workflow_name: run.workflow_name.clone(),
524                from: run.status.state,
525                to,
526                error: None,
527                cost_usd: run.cost_usd,
528                duration_ms: run.duration_ms,
529                labels: run.labels.clone(),
530                at: Utc::now(),
531            }));
532    }
533}