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}