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}