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, WorkflowId,
13 WorkflowListPage, WorkflowListRequest, 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 /// the boot/adoption sweep'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 /// Record a new operator-facing display name for a run (#211).
402 ///
403 /// The name is a LABEL over the UUID identity, never an address: nothing
404 /// resolves a workflow by name. Every rename is a RECORDED
405 /// `SearchAttributesUpdated` event (the `aion.display_name` attribute), so
406 /// history keeps every name that has been worn and the current name is the
407 /// last-write-wins fold.
408 ///
409 /// Both halves of that are true at once and are easy to confuse. You
410 /// ADDRESS a run — this operation takes a workflow id AND a run id, because
411 /// which run is live decides whether the append is safe — but what you
412 /// RECORD is a workflow-level attribute with no run id in it. Readers fold
413 /// it over the whole history, so the name reads back for every run of the
414 /// workflow, a continue-as-new successor inherits it, and (below) a
415 /// superseded predecessor cannot be renamed at all.
416 ///
417 /// There is no status precondition on the label itself — a completed run's
418 /// name is as legitimate as a running one's. Status decides only HOW the
419 /// event is appended: a REGISTERED run appends through its own
420 /// single-writer recorder under that recorder's lock, while a run with no
421 /// registered handle appends through a one-shot recorder at the durable
422 /// head — and only when its durable status proves no live recorder can
423 /// exist (terminal, or `Paused` and so excluded from respawn by design).
424 /// A non-resident run in any other status is REFUSED rather than raced.
425 /// A run superseded by a later run of the same workflow is refused too:
426 /// `SearchAttributesUpdated` carries no run id, so a recorded name would
427 /// land on the later run. So is a run that has recorded its own
428 /// `WorkflowContinuedAsNew` and is therefore ABOUT to be superseded — its
429 /// successor's recorder has already read the head this rename would append
430 /// to, and appending there would strand the chain with no successor run.
431 ///
432 /// Returns the name exactly as recorded (trimmed).
433 ///
434 /// # Errors
435 ///
436 /// Returns [`EngineError::ShuttingDown`] after shutdown begins;
437 /// [`EngineError::InvalidState`] when the trimmed name is empty, when the
438 /// run is non-terminal, not `Paused`, and not resident on this node (RETRY
439 /// once it is resident), or when the run has been superseded or has
440 /// continued as new;
441 /// [`EngineError::WorkflowNotFound`] when no history exists for the pair;
442 /// and [`EngineError::Durability`] when the schema refuses the attribute or
443 /// the store rejects the append. Every rejection appends nothing.
444 pub async fn rename_workflow(
445 &self,
446 id: &WorkflowId,
447 run: &RunId,
448 display_name: &str,
449 ) -> Result<String, EngineError> {
450 let operation = self.shutdown_gate.begin_operation()?;
451 let context = crate::lifecycle::RenameWorkflowContext {
452 store: self.store(),
453 visibility_store: Arc::clone(&self.visibility_store),
454 registry: &self.registry,
455 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
456 };
457 let result = crate::lifecycle::rename::rename(&context, id, run, display_name).await;
458 drop(operation);
459 result
460 }
461
462 /// Resume a `Paused` run, releasing the dispatch hold (#204).
463 ///
464 /// Named `resume_paused_workflow` to avoid colliding with the existing
465 /// residency-flip [`Engine::resume_workflow`]. Appends `WorkflowResumed`,
466 /// removes the run from the dispatch-hold set, and — when the run crashed
467 /// while paused and is no longer resident — respawns it via the reopen
468 /// recovery path, re-arming unfired timers. The ordinary sweep then claims
469 /// the released rows.
470 ///
471 /// # Errors
472 ///
473 /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
474 /// [`EngineError::WorkflowNotFound`] when the pair has no history, and
475 /// [`EngineError::InvalidState`] — naming the actual status — when the run is
476 /// not `Paused`.
477 pub async fn resume_paused_workflow(
478 &self,
479 id: &WorkflowId,
480 run: &RunId,
481 operator: Option<String>,
482 ) -> Result<WorkflowHandle, EngineError> {
483 let operation = self.shutdown_gate.begin_operation()?;
484 let result =
485 crate::lifecycle::pause::resume(&self.pause_context(), id, run, operator).await;
486 drop(operation);
487 result
488 }
489
490 /// Await a workflow run's terminal result.
491 ///
492 /// Already-terminal histories return immediately. Live workflows await their
493 /// completion notifier. Unknown workflow/run pairs return not found.
494 ///
495 /// # Errors
496 ///
497 /// Returns store, registry, or runtime channel errors as typed [`EngineError`]
498 /// variants, or [`EngineError::WorkflowNotFound`] when no live handle or
499 /// terminal history exists for the requested pair.
500 pub async fn result(
501 &self,
502 id: &WorkflowId,
503 run: &RunId,
504 ) -> Result<Result<Payload, WorkflowError>, EngineError> {
505 let history = self.store.read_history(id).await?;
506 if let Some(outcome) = terminal_outcome_from_history(&history) {
507 return Ok(outcome_to_result(outcome));
508 }
509
510 let handle = match self.registry.get(id, run)? {
511 Some(handle) => handle,
512 // Registration birth window: the run is durably started but its
513 // handle insert has not landed yet (see
514 // `Engine::handle_after_birth_window`).
515 None => self
516 .handle_after_birth_window(id, run, &history)
517 .await?
518 .ok_or_else(|| workflow_not_found(id, run))?,
519 };
520 let mut receiver = handle.completion().subscribe();
521 loop {
522 if let Some(outcome) = receiver.borrow().clone() {
523 return Ok(outcome_to_result(outcome));
524 }
525 if receiver.changed().await.is_err() {
526 if let Some(outcome) =
527 terminal_outcome_from_history(&self.store.read_history(id).await?)
528 {
529 return Ok(outcome_to_result(outcome));
530 }
531 return Err(EngineError::Runtime {
532 reason: format!(
533 "completion channel closed before workflow `{id}/{run}` finished"
534 ),
535 });
536 }
537 }
538 }
539
540 /// Answers one page of the workflow list contract from the visibility
541 /// projection.
542 ///
543 /// The projection is the single source for listing: every durable append
544 /// maintains its row through the Recorder, and boot, adoption, and the
545 /// periodic repair loop reconcile it with history. Nothing here reads
546 /// history or the live registry.
547 ///
548 /// # Errors
549 ///
550 /// Returns [`EngineError::Store`] wrapping
551 /// [`aion_store::StoreError::InvalidQuery`] for a zero limit or a cursor
552 /// minted under a different query, and typed store errors when the
553 /// projection cannot be read.
554 pub async fn list_workflows(
555 &self,
556 request: &WorkflowListRequest,
557 ) -> Result<WorkflowListPage, EngineError> {
558 let page = self.visibility_store.list_workflows(request).await?;
559 Ok(WorkflowListPage {
560 items: page.items.into_iter().map(WorkflowSummary::from).collect(),
561 next_cursor: page.next_cursor,
562 count: page.count,
563 // The engine has no dispatcher and so no lease-record ledger; the
564 // server stamps the install's provenance before the page leaves it.
565 provenance: None,
566 })
567 }
568}
569
570fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
571 match outcome {
572 TerminalOutcome::Completed(payload) => Ok(payload),
573 TerminalOutcome::Failed(error) => Err(error),
574 TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
575 message: format!("workflow cancelled: {reason}"),
576 details: None,
577 }),
578 TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
579 message: format!("workflow timed out: {timeout}"),
580 details: None,
581 }),
582 TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
583 message: format!("workflow continued as new from run {parent_run_id}"),
584 details: None,
585 }),
586 }
587}