Skip to main content

aion/lifecycle/
terminate.rs

1//! cancel/complete/fail transitions
2
3use std::sync::Arc;
4
5use aion_core::{Payload, RunId, WorkflowError, WorkflowId};
6use aion_store::EventStore;
7use aion_store::visibility::VisibilityStore;
8use chrono::Utc;
9
10use crate::EngineError;
11use crate::registry::{Registry, TerminalOutcome, WorkflowHandle};
12use crate::runtime::RuntimeHandle;
13
14use super::visibility::upsert_workflow_visibility;
15
16/// Dependencies required to drive a workflow to a terminal lifecycle state.
17pub struct TerminateWorkflowContext<'a> {
18    /// Runtime boundary used to cancel live workflow processes.
19    pub runtime: &'a RuntimeHandle,
20    /// Durable event store used to rebuild visibility projections.
21    pub store: Arc<dyn EventStore>,
22    /// Visibility index updated after state-changing workflow events.
23    pub visibility_store: Arc<dyn VisibilityStore>,
24    /// Active execution registry keyed by workflow/run identifiers.
25    pub registry: &'a Registry,
26}
27
28/// Completes a live workflow run with its terminal result payload.
29///
30/// # Errors
31///
32/// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is
33/// not registered. Recorder and registry failures surface as their typed
34/// [`EngineError`] variants.
35pub async fn complete(
36    context: TerminateWorkflowContext<'_>,
37    id: &WorkflowId,
38    run: &RunId,
39    result: Payload,
40) -> Result<(), EngineError> {
41    let handle = registered_handle(context.registry, id, run)?;
42    {
43        let recorder = handle.recorder();
44        let mut recorder = recorder.lock().await;
45        let history = context.store.read_history(id).await?;
46        reject_if_recorded_terminal(&history, id, run)?;
47        recorder
48            .record_workflow_completed(Utc::now(), result.clone())
49            .await?;
50        // D5: retire this run's declared-timeout deadline as part of the terminal
51        // transition — under the same recorder lock, on the pre-terminal history
52        // so the deadline still reads live. Shared with every other terminal
53        // writer via `retire_run_deadline`.
54        crate::time::retire_run_deadline(&mut recorder, &history, run).await?;
55    }
56    upsert_workflow_visibility(context.store, context.visibility_store, id, run).await?;
57
58    handle
59        .completion()
60        .notify(TerminalOutcome::Completed(result));
61    context.registry.remove(id, run)?;
62    Ok(())
63}
64
65/// Fails a live workflow run with its terminal workflow error.
66///
67/// # Errors
68///
69/// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is
70/// not registered. Recorder and registry failures surface as their typed
71/// [`EngineError`] variants.
72pub async fn fail(
73    context: TerminateWorkflowContext<'_>,
74    id: &WorkflowId,
75    run: &RunId,
76    error: WorkflowError,
77) -> Result<(), EngineError> {
78    let handle = registered_handle(context.registry, id, run)?;
79    {
80        let recorder = handle.recorder();
81        let mut recorder = recorder.lock().await;
82        let history = context.store.read_history(id).await?;
83        reject_if_recorded_terminal(&history, id, run)?;
84        recorder
85            .record_workflow_failed(Utc::now(), error.clone())
86            .await?;
87        // D5: retire this run's declared-timeout deadline (see `complete`).
88        crate::time::retire_run_deadline(&mut recorder, &history, run).await?;
89    }
90    upsert_workflow_visibility(context.store, context.visibility_store, id, run).await?;
91
92    handle.completion().notify(TerminalOutcome::Failed(error));
93    context.registry.remove(id, run)?;
94    Ok(())
95}
96
97/// Cancels a live workflow run, relying on runtime link propagation to tear down
98/// any linked activity children.
99///
100/// # Errors
101///
102/// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is
103/// not registered. Runtime cancellation, recorder, and registry failures surface
104/// as their typed [`EngineError`] variants.
105pub async fn cancel(
106    context: TerminateWorkflowContext<'_>,
107    id: &WorkflowId,
108    run: &RunId,
109    reason: impl Into<String>,
110) -> Result<(), EngineError> {
111    let handle = registered_handle(context.registry, id, run)?;
112    let reason = reason.into();
113    // Record the durable cancellation BEFORE killing the process: the exit
114    // monitor records WorkflowFailed for any kill it observes without a
115    // terminal event already in history.
116    {
117        let recorder = handle.recorder();
118        let mut recorder = recorder.lock().await;
119        let history = context.store.read_history(id).await?;
120        reject_if_recorded_terminal(&history, id, run)?;
121        recorder
122            .record_workflow_cancelled(Utc::now(), reason.clone())
123            .await?;
124        // D5: retire this run's declared-timeout deadline (see `complete`).
125        crate::time::retire_run_deadline(&mut recorder, &history, run).await?;
126    }
127    if let Err(error) = context.runtime.cancel_pid(handle.pid()) {
128        // The process exited between the durable cancel record and the kill;
129        // its exit monitor reconciles against the recorded cancellation.
130        tracing::debug!(
131            workflow_id = %id,
132            run_id = %run,
133            error = %error,
134            "workflow process already exited during cancel"
135        );
136    }
137    upsert_workflow_visibility(context.store, context.visibility_store, id, run).await?;
138
139    handle
140        .completion()
141        .notify(TerminalOutcome::Cancelled(reason));
142    context.registry.remove(id, run)?;
143    Ok(())
144}
145
146/// Rejects a terminal transition when the run already recorded one.
147///
148/// Must be called on the history read while holding the handle's recorder lock:
149/// the exit monitor records terminal events through the same recorder, and only
150/// the lock makes this check-then-record atomic against it. Taking `history` (the
151/// same read used to derive the deadline for retirement) keeps the transition to
152/// a single locked history read.
153fn reject_if_recorded_terminal(
154    history: &[aion_core::Event],
155    id: &WorkflowId,
156    run: &RunId,
157) -> Result<(), EngineError> {
158    if super::completion::terminal_outcome_from_history(history, run).is_some() {
159        return Err(EngineError::Runtime {
160            reason: format!("workflow {id} run {run} already recorded a terminal event"),
161        });
162    }
163    Ok(())
164}
165
166fn registered_handle(
167    registry: &Registry,
168    id: &WorkflowId,
169    run: &RunId,
170) -> Result<WorkflowHandle, EngineError> {
171    registry
172        .get(id, run)?
173        .ok_or_else(|| EngineError::WorkflowNotFound {
174            workflow_type: format!("{id}/{run}"),
175        })
176}
177
178#[cfg(test)]
179mod tests {
180    use std::sync::Arc;
181
182    use aion_core::{Event, Payload, WorkflowStatus};
183    use aion_package::ContentHash;
184    use aion_store::visibility::VisibilityStore;
185    use aion_store::{EventStore, InMemoryStore};
186    use serde_json::json;
187
188    use super::{TerminateWorkflowContext, cancel, complete, fail};
189    use crate::EngineError;
190    use crate::durability::Recorder;
191    use crate::registry::{
192        CompletionNotifier, HandleResidency, Registry, TerminalOutcome, WorkflowHandle,
193        WorkflowHandleParts,
194    };
195    use crate::runtime::{RuntimeConfig, RuntimeHandle};
196
197    struct ActiveWorkflow {
198        store: Arc<dyn EventStore>,
199        visibility_store: Arc<dyn VisibilityStore>,
200        runtime: RuntimeHandle,
201        registry: Registry,
202        handle: WorkflowHandle,
203    }
204
205    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
206        Payload::from_json(&json!({ "label": label }))
207    }
208
209    fn workflow_error(message: &str) -> aion_core::WorkflowError {
210        aion_core::WorkflowError {
211            message: message.to_owned(),
212            details: None,
213        }
214    }
215
216    async fn active_workflow() -> Result<ActiveWorkflow, Box<dyn std::error::Error>> {
217        let backing = Arc::new(InMemoryStore::default());
218        let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
219        let visibility_store: Arc<dyn VisibilityStore> = backing;
220        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
221        let registry = Registry::default();
222        let workflow_id = aion_core::WorkflowId::new_v4();
223        let run_id = aion_core::RunId::new_v4();
224        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
225        recorder
226            .record_workflow_started(
227                chrono::Utc::now(),
228                crate::durability::WorkflowStartRecord {
229                    workflow_type: "checkout".to_owned(),
230                    input: payload("input")?,
231                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
232                    parent_run_id: None,
233                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
234                },
235            )
236            .await?;
237        let pid = runtime.spawn_test_process_with_trap_exit(true)?;
238        let completion = CompletionNotifier::new();
239        let handle = WorkflowHandle::new(WorkflowHandleParts {
240            workflow_id: workflow_id.clone(),
241            run_id: run_id.clone(),
242            pid,
243            workflow_type: "checkout".to_owned(),
244            namespace: String::from("default"),
245            loaded_version: ContentHash::from_bytes([9; 32]),
246            cached_status: WorkflowStatus::Running,
247            residency: HandleResidency::Resident,
248            recorder,
249            completion,
250        });
251        registry.insert((workflow_id, run_id), handle.clone())?;
252
253        Ok(ActiveWorkflow {
254            store,
255            visibility_store,
256            runtime,
257            registry,
258            handle,
259        })
260    }
261
262    fn context<'a>(
263        runtime: &'a RuntimeHandle,
264        store: Arc<dyn EventStore>,
265        visibility_store: Arc<dyn VisibilityStore>,
266        registry: &'a Registry,
267    ) -> TerminateWorkflowContext<'a> {
268        TerminateWorkflowContext {
269            runtime,
270            store,
271            visibility_store,
272            registry,
273        }
274    }
275
276    #[tokio::test]
277    async fn complete_records_notifies_and_deregisters() -> Result<(), Box<dyn std::error::Error>> {
278        let active = active_workflow().await?;
279        let result = payload("result")?;
280        let mut receiver = active.handle.completion().subscribe();
281
282        complete(
283            context(
284                &active.runtime,
285                active.store.clone(),
286                active.visibility_store.clone(),
287                &active.registry,
288            ),
289            active.handle.workflow_id(),
290            active.handle.run_id(),
291            result.clone(),
292        )
293        .await?;
294        receiver.changed().await?;
295
296        assert_eq!(
297            receiver.borrow().clone(),
298            Some(TerminalOutcome::Completed(result.clone()))
299        );
300        assert_eq!(
301            active
302                .registry
303                .get(active.handle.workflow_id(), active.handle.run_id())?,
304            None
305        );
306        let history = active
307            .store
308            .read_history(active.handle.workflow_id())
309            .await?;
310        match history.as_slice() {
311            [
312                Event::WorkflowStarted { .. },
313                Event::WorkflowCompleted {
314                    envelope,
315                    result: recorded,
316                },
317            ] => {
318                assert_eq!(envelope.seq, 2);
319                assert_eq!(recorded, &result);
320            }
321            other => return Err(format!("expected started then completed, found {other:?}").into()),
322        }
323        active.runtime.shutdown()?;
324        Ok(())
325    }
326
327    #[tokio::test]
328    async fn fail_records_notifies_and_deregisters() -> Result<(), Box<dyn std::error::Error>> {
329        let active = active_workflow().await?;
330        let error = workflow_error("workflow failed");
331        let mut receiver = active.handle.completion().subscribe();
332
333        fail(
334            context(
335                &active.runtime,
336                active.store.clone(),
337                active.visibility_store.clone(),
338                &active.registry,
339            ),
340            active.handle.workflow_id(),
341            active.handle.run_id(),
342            error.clone(),
343        )
344        .await?;
345        receiver.changed().await?;
346
347        assert_eq!(
348            receiver.borrow().clone(),
349            Some(TerminalOutcome::Failed(error.clone()))
350        );
351        assert_eq!(
352            active
353                .registry
354                .get(active.handle.workflow_id(), active.handle.run_id())?,
355            None
356        );
357        let history = active
358            .store
359            .read_history(active.handle.workflow_id())
360            .await?;
361        match history.as_slice() {
362            [
363                Event::WorkflowStarted { .. },
364                Event::WorkflowFailed {
365                    envelope,
366                    error: recorded,
367                },
368            ] => {
369                assert_eq!(envelope.seq, 2);
370                assert_eq!(recorded, &error);
371            }
372            other => return Err(format!("expected started then failed, found {other:?}").into()),
373        }
374        active.runtime.shutdown()?;
375        Ok(())
376    }
377
378    #[tokio::test]
379    async fn cancel_kills_linked_children_records_notifies_and_deregisters()
380    -> Result<(), Box<dyn std::error::Error>> {
381        let active = active_workflow().await?;
382        let child = active
383            .runtime
384            .spawn_linked_test_process(active.handle.pid())?;
385        let reason = String::from("caller requested cancellation");
386        let mut receiver = active.handle.completion().subscribe();
387
388        cancel(
389            context(
390                &active.runtime,
391                active.store.clone(),
392                active.visibility_store.clone(),
393                &active.registry,
394            ),
395            active.handle.workflow_id(),
396            active.handle.run_id(),
397            reason.clone(),
398        )
399        .await?;
400        receiver.changed().await?;
401
402        assert!(!active.runtime.is_live(active.handle.pid()));
403        assert!(!active.runtime.is_live(child));
404        assert_eq!(
405            receiver.borrow().clone(),
406            Some(TerminalOutcome::Cancelled(reason.clone()))
407        );
408        assert_eq!(
409            active
410                .registry
411                .get(active.handle.workflow_id(), active.handle.run_id())?,
412            None
413        );
414        let history = active
415            .store
416            .read_history(active.handle.workflow_id())
417            .await?;
418        match history.as_slice() {
419            [
420                Event::WorkflowStarted { .. },
421                Event::WorkflowCancelled {
422                    envelope,
423                    reason: recorded,
424                },
425            ] => {
426                assert_eq!(envelope.seq, 2);
427                assert_eq!(recorded, &reason);
428            }
429            other => return Err(format!("expected started then cancelled, found {other:?}").into()),
430        }
431        active.runtime.shutdown()?;
432        Ok(())
433    }
434
435    #[tokio::test]
436    async fn complete_cancels_an_armed_deadline() -> Result<(), Box<dyn std::error::Error>> {
437        // Universality of D5: the public `complete` terminal writer retires the
438        // run's declared-timeout deadline as part of its transition, so no
439        // terminal writer leaves a deadline outstanding.
440        let active = active_workflow().await?;
441        let run_id = active.handle.run_id().clone();
442        let deadline_id = crate::time::deadline_timer_id(&run_id)?;
443        {
444            let recorder = active.handle.recorder();
445            let mut recorder = recorder.lock().await;
446            recorder
447                .record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
448                .await?;
449        }
450
451        complete(
452            context(
453                &active.runtime,
454                active.store.clone(),
455                active.visibility_store.clone(),
456                &active.registry,
457            ),
458            active.handle.workflow_id(),
459            active.handle.run_id(),
460            payload("result")?,
461        )
462        .await?;
463
464        let history = active
465            .store
466            .read_history(active.handle.workflow_id())
467            .await?;
468        assert_eq!(
469            crate::time::outstanding_deadline_timer(&history, &run_id),
470            None,
471            "complete retires the declared-timeout deadline: {history:#?}"
472        );
473        assert!(
474            history.iter().any(|event| matches!(
475                event,
476                Event::TimerCancelled { timer_id, .. } if timer_id == &deadline_id
477            )),
478            "a TimerCancelled for the deadline is recorded: {history:#?}"
479        );
480        active.runtime.shutdown()?;
481        Ok(())
482    }
483
484    #[tokio::test]
485    async fn cancel_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
486        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
487        let registry = Registry::default();
488        let workflow_id = aion_core::WorkflowId::new_v4();
489        let run_id = aion_core::RunId::new_v4();
490
491        let result = cancel(
492            context(
493                &runtime,
494                Arc::new(InMemoryStore::default()),
495                Arc::new(InMemoryStore::default()),
496                &registry,
497            ),
498            &workflow_id,
499            &run_id,
500            "missing workflow",
501        )
502        .await;
503
504        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
505        runtime.shutdown()?;
506        Ok(())
507    }
508}