Skip to main content

aion/engine/
api_workflow_ops.rs

1//! The workflow operations [`Engine`] exposes to a caller.
2//!
3//! Split from [`super::api`], which keeps the engine's own lifecycle — its
4//! construction, its seam accessors, shard adoption and shutdown. Everything
5//! here acts on a WORKFLOW rather than on the engine: starting, cancelling,
6//! continuing as new, reopening, pausing, awaiting a result, and listing.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use aion_core::{
12    Payload, RunId, SearchAttributeValue, TimerCancelCause, WorkflowError, WorkflowFilter,
13    WorkflowId, WorkflowSummary,
14};
15
16use crate::EngineError;
17use crate::lifecycle::continue_as_new::{self, ContinueAsNewContext, ContinueAsNewRequest};
18use crate::lifecycle::reopen::{self, ReopenWorkflowContext};
19use crate::lifecycle::start::{self, StartWorkflowContext};
20use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
21use crate::lifecycle::transition;
22use crate::registry::{TerminalOutcome, WorkflowHandle};
23use crate::time::timer_service::live_timers_in_active_segment;
24
25use super::api::{Engine, terminal_outcome_from_history, workflow_not_found};
26
27impl Engine {
28    /// Start a loaded workflow type as a new BEAM process.
29    ///
30    /// `search_attributes` are validated against the engine's configured
31    /// [`aion_core::SearchAttributeSchema`] and recorded atomically with the
32    /// `WorkflowStarted` event, so visibility metadata can never be lost to a
33    /// crash between start and a later attribute update.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
38    /// [`EngineError::Durability`] when a search attribute is unregistered or
39    /// mistyped (nothing is appended and no process is spawned). Otherwise
40    /// delegates to the start lifecycle transition and returns its typed errors.
41    pub async fn start_workflow(
42        &self,
43        workflow_type: &str,
44        input: Payload,
45        search_attributes: HashMap<String, SearchAttributeValue>,
46        namespace: String,
47    ) -> Result<WorkflowHandle, EngineError> {
48        self.start_workflow_with_id(
49            workflow_type,
50            input,
51            search_attributes,
52            namespace,
53            None,
54            None,
55        )
56        .await
57    }
58
59    /// Start a loaded workflow type, optionally with a caller-chosen
60    /// `workflow_id` and/or R-4 steered-start `routing_key`.
61    ///
62    /// The request-routing edge supplies `workflow_id` to *place* a new start on
63    /// a shard this node owns: the R-1 unsteered-start remint (any locally-owned
64    /// shard) or, for a steered start, an id the edge derived on the
65    /// `routing_key`'s shard before deciding to run locally. So a `start` whose
66    /// id would otherwise hash to a non-owned shard never fences. When
67    /// `workflow_id` is `None` this is identical to [`Self::start_workflow`]: the
68    /// lifecycle mints a fresh `WorkflowId`, so the default single-node path is
69    /// unchanged.
70    ///
71    /// `routing_key` is the caller-chosen steered-start key recorded on the start
72    /// options. Shard derivation for the cluster path is performed at the edge
73    /// (which holds the concrete cluster store); here it is threaded through for
74    /// API completeness and direct callers.
75    ///
76    /// # Errors
77    ///
78    /// Identical to [`Self::start_workflow`]. A supplied `workflow_id` is treated
79    /// as a fresh execution; the caller is responsible for choosing an unused id.
80    pub async fn start_workflow_with_id(
81        &self,
82        workflow_type: &str,
83        input: Payload,
84        search_attributes: HashMap<String, SearchAttributeValue>,
85        namespace: String,
86        workflow_id: Option<WorkflowId>,
87        routing_key: Option<String>,
88    ) -> Result<WorkflowHandle, EngineError> {
89        let operation = self.shutdown_gate.begin_start()?;
90        let result = start::start_workflow_with_options(
91            StartWorkflowContext {
92                store: self.store(),
93                visibility_store: self.visibility_store(),
94                catalog: Arc::clone(&self.catalog),
95                runtime: Arc::clone(&self.runtime),
96                supervision: Arc::clone(&self.supervision),
97                registry: Arc::clone(&self.registry),
98                signal_handoff: Some(self.signal_handoff()),
99                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
100                monitor_tokio_handle: tokio::runtime::Handle::current(),
101            },
102            workflow_type,
103            input,
104            start::StartWorkflowOptions {
105                namespace: Some(namespace),
106                search_attributes,
107                workflow_id,
108                routing_key,
109                // THE start boundary: every transport (HTTP, gRPC, WebSocket,
110                // CLI, in-process client) reaches the engine through here, and
111                // this is the only start path whose input came from outside.
112                input_admission: start::InputAdmission::Declared,
113                ..start::StartWorkflowOptions::default()
114            },
115        )
116        .await;
117        drop(operation);
118        result
119    }
120
121    /// Resume a suspended workflow run and flush deferred signals through its mailbox.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
126    /// is absent, or registry errors from the residency transition. Deferred
127    /// delivery failures are logged and dropped because signals are already durable.
128    pub fn resume_workflow(
129        &self,
130        id: &WorkflowId,
131        run: &RunId,
132    ) -> Result<WorkflowHandle, EngineError> {
133        let handle = transition::resume(self.registry(), id, run)?;
134        if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
135            tracing::warn!(
136                workflow_id = %id,
137                run_id = %run,
138                error = %error,
139                "failed to flush deferred signals after workflow resume"
140            );
141        }
142        Ok(handle)
143    }
144
145    /// Cancel a live workflow run by killing its runtime process.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
150    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
151    /// is not live. Other typed errors come from the cancel transition.
152    pub async fn cancel(
153        &self,
154        id: &WorkflowId,
155        run: &RunId,
156        reason: impl Into<String>,
157    ) -> Result<(), EngineError> {
158        let operation = self.shutdown_gate.begin_operation()?;
159        // Tear down the run's in-flight durable timers BEFORE the cancel
160        // transition. Cancellation that leaves a live timer behind orphans it:
161        // recovery later tries to fire it against a workflow that no longer
162        // exists. See `cancel_inflight_timers` for the ordering constraints.
163        self.cancel_inflight_timers(id).await;
164        let result = terminate::cancel(
165            TerminateWorkflowContext {
166                runtime: &self.runtime,
167                store: self.store(),
168                visibility_store: self.visibility_store(),
169                registry: &self.registry,
170                catalog: &self.catalog,
171            },
172            id,
173            run,
174            reason,
175        )
176        .await;
177        // Cancel of a Paused run must release the dispatch hold so its held rows
178        // are not leaked forever (#204, GATE-4). Removal is unconditional and
179        // idempotent: a non-paused run is simply absent from the set.
180        if result.is_ok() {
181            self.paused_runs.remove(id);
182        }
183        drop(operation);
184        result
185    }
186
187    /// Cancel the workflow's in-flight durable timers, routed through the
188    /// production [`crate::time::TimerService`] so each records a
189    /// `TimerCancelled` (and disarms the resident wheel) under the service's
190    /// terminal-update guard. Once `TimerCancelled` is in history the timer is
191    /// dead everywhere — a later wheel or recovery fire no-ops on the liveness
192    /// check — so a cancelled workflow no longer leaves orphaned timers that
193    /// brick startup recovery.
194    ///
195    /// Ordering matters and is the reason this lives in `Engine::cancel` rather
196    /// than inside `terminate::cancel`:
197    /// * It runs **before** `terminate::cancel`, while the workflow is still in
198    ///   the registry (before `terminate::cancel`'s final `registry.remove`), so
199    ///   the timer bridge's registry lookup succeeds. `UnknownWorkflow` is raised
200    ///   only when the workflow is absent from the registry entirely — a
201    ///   suspended (non-resident-but-registered) workflow is fine: its wheel
202    ///   disarm is skipped but `TimerCancelled` is still recorded.
203    /// * It runs **outside** `terminate::cancel`'s recorder lock —
204    ///   `TimerService::cancel` re-acquires that same per-handle lock to record,
205    ///   and the tokio mutex is not reentrant.
206    ///
207    /// Best-effort by design: every failure path here is backstopped by
208    /// `recover_due`'s orphaned-timer skip (see [`crate::time`]'s recovery
209    /// module), so it is logged but never fails the cancel. The only residual
210    /// orphan window — a timer armed in the instant between enumeration and the
211    /// process kill — is absorbed by that same recovery skip.
212    async fn cancel_inflight_timers(&self, id: &WorkflowId) {
213        let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
214            self.runtime.nif_state(),
215        ) {
216            Ok(service) => service,
217            Err(error) => {
218                tracing::warn!(
219                    %error,
220                    workflow_id = %id,
221                    "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
222                );
223                return;
224            }
225        };
226        let history = match self.store.read_history(id).await {
227            Ok(history) => history,
228            Err(error) => {
229                tracing::warn!(
230                    %error,
231                    workflow_id = %id,
232                    "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
233                );
234                return;
235            }
236        };
237        for timer_id in live_timers_in_active_segment(&history) {
238            // A reserved workflow-deadline timer is retired PERMANENTLY
239            // (`WorkflowIntent`): reopen must never resurrect it (a
240            // `CancelTeardown` deadline would be re-armed at its original
241            // `fire_at` by `rearmable_timers`). Every other in-flight timer is
242            // ordinary cancel-teardown bookkeeping that reopen re-arms.
243            let cause = if crate::time::is_deadline_timer(&timer_id) {
244                TimerCancelCause::WorkflowIntent
245            } else {
246                TimerCancelCause::CancelTeardown
247            };
248            if let Err(error) = timer_service
249                .cancel(id.clone(), timer_id.clone(), cause)
250                .await
251            {
252                tracing::warn!(
253                    %error,
254                    workflow_id = %id,
255                    %timer_id,
256                    "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
257                );
258            }
259        }
260    }
261
262    /// Continue a live workflow run as a new run under the same workflow id.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
267    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
268    /// is not live. Other typed errors come from the continue-as-new transition.
269    pub async fn continue_as_new(
270        &self,
271        id: &WorkflowId,
272        run: &RunId,
273        input: Payload,
274        workflow_type: Option<String>,
275    ) -> Result<WorkflowHandle, EngineError> {
276        let operation = self.shutdown_gate.begin_operation()?;
277        let result = continue_as_new::continue_as_new(
278            ContinueAsNewContext {
279                store: self.store(),
280                visibility_store: Arc::clone(&self.visibility_store),
281                catalog: Arc::clone(&self.catalog),
282                runtime: &self.runtime,
283                supervision: Arc::clone(&self.supervision),
284                registry: &self.registry,
285                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
286            },
287            id,
288            run,
289            ContinueAsNewRequest {
290                input,
291                workflow_type,
292            },
293        )
294        .await;
295        drop(operation);
296        result
297    }
298
299    /// Reopen a terminal-`Failed` or terminal-`Cancelled` run and re-drive it.
300    ///
301    /// Appends a single `WorkflowReopened` that supersedes the run's terminal
302    /// event (returning it to Running), then respawns and re-drives the SAME run
303    /// through the existing recovery path so replay returns every recorded result
304    /// and only the reopened / in-flight step re-dispatches live, in the
305    /// workflow's own namespace. Takes only a workflow id and run; the reopened
306    /// steps and the namespace are derived from history.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
311    /// [`EngineError::WorkflowNotFound`] when no history exists for the pair, and
312    /// [`EngineError::InvalidState`] when the run is not a reopenable terminal
313    /// (not terminal, terminal for Completed/`TimedOut`, or already Running).
314    pub async fn reopen_workflow(
315        &self,
316        id: &WorkflowId,
317        run: &RunId,
318    ) -> Result<WorkflowHandle, EngineError> {
319        let operation = self.shutdown_gate.begin_operation()?;
320        let result = reopen::reopen(
321            ReopenWorkflowContext {
322                store: self.store(),
323                visibility_store: Arc::clone(&self.visibility_store),
324                catalog: Arc::clone(&self.catalog),
325                runtime: &self.runtime,
326                supervision: Arc::clone(&self.supervision),
327                registry: &self.registry,
328                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
329            },
330            id,
331            run,
332        )
333        .await;
334        drop(operation);
335        result
336    }
337
338    /// The shared dispatch-hold set for durable pause (#204).
339    ///
340    /// Handed to the outbox dispatcher at wiring time so a held (paused) run's
341    /// rows are never claimed, and rebuilt from [`aion_store::EventStore::list_paused`] at
342    /// startup/adoption.
343    #[must_use]
344    pub fn paused_runs(&self) -> crate::lifecycle::PausedRuns {
345        self.paused_runs.clone()
346    }
347
348    /// Rebuild the dispatch-hold set from durable state (startup / shard
349    /// adoption). A run projecting `Paused` is excluded from `list_active`
350    /// respawn for free; this repopulates the hold so its pre-pause outbox rows
351    /// stay unclaimed after a restart.
352    ///
353    /// # Errors
354    ///
355    /// Returns store errors from the `list_paused` scan.
356    pub async fn rebuild_paused_runs(&self) -> Result<(), EngineError> {
357        let paused = self.store.list_paused().await?;
358        self.paused_runs.replace_all(paused);
359        Ok(())
360    }
361
362    fn pause_context(&self) -> crate::lifecycle::PauseWorkflowContext<'_> {
363        crate::lifecycle::PauseWorkflowContext {
364            store: self.store(),
365            visibility_store: Arc::clone(&self.visibility_store),
366            catalog: Arc::clone(&self.catalog),
367            runtime: &self.runtime,
368            supervision: Arc::clone(&self.supervision),
369            registry: &self.registry,
370            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
371            paused_runs: self.paused_runs.clone(),
372        }
373    }
374
375    /// Pause a live `Running` run, durably holding NEW activity dispatch (#204).
376    ///
377    /// Appends `WorkflowPaused` through the resident handle's own recorder and
378    /// inserts the run into the dispatch-hold set; the resident process stays
379    /// alive and keeps recording (timer fires, signals, drained completions).
380    ///
381    /// # Errors
382    ///
383    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
384    /// [`EngineError::WorkflowNotFound`] when the pair has no history / no
385    /// resident handle, and [`EngineError::InvalidState`] — naming the actual
386    /// status — when the run is not `Running`.
387    pub async fn pause_workflow(
388        &self,
389        id: &WorkflowId,
390        run: &RunId,
391        reason: Option<String>,
392        operator: Option<String>,
393    ) -> Result<WorkflowHandle, EngineError> {
394        let operation = self.shutdown_gate.begin_operation()?;
395        let result =
396            crate::lifecycle::pause::pause(&self.pause_context(), id, run, reason, operator).await;
397        drop(operation);
398        result
399    }
400
401    /// Resume a `Paused` run, releasing the dispatch hold (#204).
402    ///
403    /// Named `resume_paused_workflow` to avoid colliding with the existing
404    /// residency-flip [`Engine::resume_workflow`]. Appends `WorkflowResumed`,
405    /// removes the run from the dispatch-hold set, and — when the run crashed
406    /// while paused and is no longer resident — respawns it via the reopen
407    /// recovery path, re-arming unfired timers. The ordinary sweep then claims
408    /// the released rows.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
413    /// [`EngineError::WorkflowNotFound`] when the pair has no history, and
414    /// [`EngineError::InvalidState`] — naming the actual status — when the run is
415    /// not `Paused`.
416    pub async fn resume_paused_workflow(
417        &self,
418        id: &WorkflowId,
419        run: &RunId,
420        operator: Option<String>,
421    ) -> Result<WorkflowHandle, EngineError> {
422        let operation = self.shutdown_gate.begin_operation()?;
423        let result =
424            crate::lifecycle::pause::resume(&self.pause_context(), id, run, operator).await;
425        drop(operation);
426        result
427    }
428
429    /// Await a workflow run's terminal result.
430    ///
431    /// Already-terminal histories return immediately. Live workflows await their
432    /// completion notifier. Unknown workflow/run pairs return not found.
433    ///
434    /// # Errors
435    ///
436    /// Returns store, registry, or runtime channel errors as typed [`EngineError`]
437    /// variants, or [`EngineError::WorkflowNotFound`] when no live handle or
438    /// terminal history exists for the requested pair.
439    pub async fn result(
440        &self,
441        id: &WorkflowId,
442        run: &RunId,
443    ) -> Result<Result<Payload, WorkflowError>, EngineError> {
444        let history = self.store.read_history(id).await?;
445        if let Some(outcome) = terminal_outcome_from_history(&history) {
446            return Ok(outcome_to_result(outcome));
447        }
448
449        let handle = match self.registry.get(id, run)? {
450            Some(handle) => handle,
451            // Registration birth window: the run is durably started but its
452            // handle insert has not landed yet (see
453            // `Engine::handle_after_birth_window`).
454            None => self
455                .handle_after_birth_window(id, run, &history)
456                .await?
457                .ok_or_else(|| workflow_not_found(id, run))?,
458        };
459        let mut receiver = handle.completion().subscribe();
460        loop {
461            if let Some(outcome) = receiver.borrow().clone() {
462                return Ok(outcome_to_result(outcome));
463            }
464            if receiver.changed().await.is_err() {
465                if let Some(outcome) =
466                    terminal_outcome_from_history(&self.store.read_history(id).await?)
467                {
468                    return Ok(outcome_to_result(outcome));
469                }
470                return Err(EngineError::Runtime {
471                    reason: format!(
472                        "completion channel closed before workflow `{id}/{run}` finished"
473                    ),
474                });
475            }
476        }
477    }
478
479    /// List live and terminal workflow summaries matching `filter`.
480    ///
481    /// Store projections are authoritative; live registry entries are projected
482    /// from durable history before being merged and deduplicated.
483    ///
484    /// # Errors
485    ///
486    /// Returns typed store or registry errors when visibility data cannot be read.
487    pub async fn list_workflows(
488        &self,
489        filter: WorkflowFilter,
490    ) -> Result<Vec<WorkflowSummary>, EngineError> {
491        let mut summaries = self
492            .store
493            .query(&filter)
494            .await?
495            .into_iter()
496            .map(|summary| (summary.workflow_id.clone(), summary))
497            .collect::<HashMap<_, _>>();
498
499        for handle in self.registry.list()? {
500            let history = self.store.read_history(handle.workflow_id()).await?;
501            self.registry
502                .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
503            if let Some(summary) = WorkflowSummary::from_history(&history) {
504                if filter.matches(&summary) {
505                    summaries.insert(summary.workflow_id.clone(), summary);
506                }
507            }
508        }
509
510        let mut summaries = summaries.into_values().collect::<Vec<_>>();
511        summaries.sort_by(|left, right| {
512            left.started_at.cmp(&right.started_at).then_with(|| {
513                left.workflow_id
514                    .to_string()
515                    .cmp(&right.workflow_id.to_string())
516            })
517        });
518        Ok(summaries)
519    }
520}
521
522fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
523    match outcome {
524        TerminalOutcome::Completed(payload) => Ok(payload),
525        TerminalOutcome::Failed(error) => Err(error),
526        TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
527            message: format!("workflow cancelled: {reason}"),
528            details: None,
529        }),
530        TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
531            message: format!("workflow timed out: {timeout}"),
532            details: None,
533        }),
534        TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
535            message: format!("workflow continued as new from run {parent_run_id}"),
536            details: None,
537        }),
538    }
539}