Skip to main content

aion/lifecycle/
completion.rs

1//! Process-exit completion handling.
2
3use std::sync::Arc;
4
5use aion_core::{
6    Event, Payload, RunId, WorkflowError, WorkflowId, current_lease_terminal, run_segment,
7};
8use aion_store::EventStore;
9use aion_store::visibility::VisibilityStore;
10use chrono::Utc;
11use tokio::runtime::Handle;
12
13use crate::EngineError;
14use crate::loader::WorkflowCatalog;
15use crate::registry::{Registry, Residency, TerminalOutcome, WorkflowHandle};
16use crate::runtime::{RuntimeHandle, WorkflowProcessOutcome};
17use crate::supervision::SupervisionTree;
18
19use super::start::{self, StartWorkflowContext, StartWorkflowOptions};
20use super::visibility::upsert_workflow_visibility;
21
22/// Owned state needed by the runtime monitor callback.
23#[derive(Clone)]
24pub struct ProcessExitContext {
25    /// Durable event store used to rebuild projections after terminal append.
26    pub store: Arc<dyn EventStore>,
27    /// Visibility index updated after terminal lifecycle events.
28    pub visibility_store: Arc<dyn VisibilityStore>,
29    /// Active execution registry to reconcile status and residency.
30    pub registry: Arc<Registry>,
31    /// Shared workflow catalog used to start continue-as-new replacements.
32    pub catalog: Arc<WorkflowCatalog>,
33    /// Runtime boundary used to spawn continue-as-new replacements.
34    pub runtime: Arc<RuntimeHandle>,
35    /// Structural supervision tree for replacement workflow placement.
36    pub supervision: Arc<SupervisionTree>,
37    /// Tokio runtime handle used to run async recorder/store work from the monitor thread.
38    pub tokio_handle: Handle,
39    /// Schema validating initial search attributes on continue-as-new replacements.
40    pub search_attribute_schema: Arc<aion_core::SearchAttributeSchema>,
41}
42
43/// Handle one observed workflow process exit.
44///
45/// The monitor calls this from outside the workflow dirty NIF thread. All durable
46/// terminal events are recorded through the handle-owned Recorder, then registry
47/// projections are reconciled from authoritative history and subscribers are
48/// notified.
49///
50/// # Errors
51///
52/// Returns typed recorder, store, visibility, or registry errors when completion
53/// cannot be durably recorded or projected.
54pub fn handle_process_exit(
55    context: ProcessExitContext,
56    handle: WorkflowHandle,
57    outcome: Result<WorkflowProcessOutcome, EngineError>,
58) -> Result<(), EngineError> {
59    context
60        .tokio_handle
61        .clone()
62        .block_on(handle_process_exit_async(context, handle, outcome))
63}
64
65async fn handle_process_exit_async(
66    context: ProcessExitContext,
67    handle: WorkflowHandle,
68    outcome: Result<WorkflowProcessOutcome, EngineError>,
69) -> Result<(), EngineError> {
70    // The terminal check and the terminal record must be atomic under the
71    // recorder lock: a concurrent cancel/complete/fail transition records
72    // through the same recorder, and a check outside the lock would let both
73    // writers append a terminal event for the same run.
74    let recorded = {
75        let recorder = handle.recorder();
76        let mut recorder = recorder.lock().await;
77        let history = context.store.read_history(handle.workflow_id()).await?;
78        if let Some(existing) = terminal_outcome_from_history(&history, handle.run_id()) {
79            // Resume an interrupted terminal transition: the terminal append and
80            // its deadline cancellation are two durable writes, so a crash
81            // between them (in a complete/fail/cancel/CAN writer) leaves the
82            // deadline outstanding. Re-encountering this run's own terminal,
83            // complete the cancellation under the recorder lock so whole-history
84            // recovery never re-arms the predecessor deadline. Idempotent: an
85            // already-cancelled (or never-armed) deadline records nothing.
86            //
87            // A `WorkflowTimedOut` terminal is the ONE exception: its deadline is
88            // owned exclusively by `WorkflowDeadlineHandler` teardown, which keeps
89            // it live as its own resume anchor across fallible visibility work and
90            // retires it LAST. This monitor is woken by that teardown's own
91            // `cancel_pid`, so retiring the deadline here would destroy the
92            // teardown's anchor mid-flight. Leave it to the handler; a re-fire via
93            // `recover_due` drives `ResumeTeardown`.
94            if !matches!(existing, TerminalOutcome::TimedOut(_)) {
95                crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
96            }
97            Err(existing)
98        } else {
99            let outcome = match outcome {
100                Ok(WorkflowProcessOutcome::Completed(result)) => {
101                    recorder
102                        .record_workflow_completed(Utc::now(), result.clone())
103                        .await?;
104                    TerminalOutcome::Completed(result)
105                }
106                Ok(WorkflowProcessOutcome::Failed(error)) => {
107                    recorder
108                        .record_workflow_failed(Utc::now(), error.clone())
109                        .await?;
110                    TerminalOutcome::Failed(error)
111                }
112                Err(error) => {
113                    let workflow_error = WorkflowError {
114                        message: format!("workflow process monitor failed: {error}"),
115                        details: None,
116                    };
117                    recorder
118                        .record_workflow_failed(Utc::now(), workflow_error.clone())
119                        .await?;
120                    TerminalOutcome::Failed(workflow_error)
121                }
122            };
123            // LAW 1 + D5: this monitor recorded the terminal, so it permanently
124            // retires the run's declared-timeout deadline under the SAME recorder
125            // lock, via the shared `retire_run_deadline` primitive. The deadline
126            // id is read from history (no speculative minting) and matched to
127            // exactly this run, so a timeout-less run — which has no such
128            // `TimerStarted` — retires nothing and touches no deadline object at
129            // all. `WorkflowIntent` keeps reopen from resurrecting it and closes
130            // the whole-history `outstanding_future_timers` re-arm hazard.
131            crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
132            Ok(outcome)
133        }
134    };
135
136    // Notify as soon as the durable terminal is decided: subscribers
137    // (result waiters, child-terminal watchers) resolve from the recorded
138    // store truth, so the doorbell must never be muted by a failure in the
139    // post-record bookkeeping below — a watcher parked on a doorbell that
140    // never rings strands the awaiting parent for the whole epoch.
141    let terminal = match recorded {
142        Err(existing) => {
143            handle.completion().notify(existing.clone());
144            // Any deadline retirement a NON-timeout pre-recorded terminal needed
145            // was already completed above, under the recorder lock, by the resume
146            // call — repairing a transition interrupted between its terminal append
147            // and its deadline cancellation. A `WorkflowTimedOut` terminal is left
148            // untouched on purpose: its deadline belongs to the deadline handler's
149            // teardown, not to this monitor.
150            reconcile_terminal_registry(&context, handle.workflow_id(), handle.run_id()).await?;
151            if let TerminalOutcome::ContinuedAsNew {
152                input,
153                workflow_type,
154                parent_run_id,
155            } = existing
156            {
157                start_continuation_replacement(
158                    &context,
159                    &handle,
160                    input,
161                    workflow_type,
162                    parent_run_id,
163                )
164                .await?;
165            }
166            return Ok(());
167        }
168        Ok(terminal) => terminal,
169    };
170    handle.completion().notify(terminal);
171
172    upsert_workflow_visibility(
173        Arc::clone(&context.store),
174        Arc::clone(&context.visibility_store),
175        handle.workflow_id(),
176        handle.run_id(),
177    )
178    .await?;
179    reconcile_terminal_registry(&context, handle.workflow_id(), handle.run_id()).await?;
180    Ok(())
181}
182
183async fn reconcile_terminal_registry(
184    context: &ProcessExitContext,
185    id: &WorkflowId,
186    run: &RunId,
187) -> Result<(), EngineError> {
188    let history = context.store.read_history(id).await?;
189    context.registry.reconcile(id, run, &history)?;
190    context
191        .registry
192        .replace_residency(id, run, Residency::Suspended)?;
193    Ok(())
194}
195
196async fn start_continuation_replacement(
197    context: &ProcessExitContext,
198    handle: &WorkflowHandle,
199    input: Payload,
200    workflow_type: Option<String>,
201    parent_run_id: RunId,
202) -> Result<(), EngineError> {
203    let replacement_type = workflow_type.as_deref().unwrap_or(handle.workflow_type());
204    let already_started = context
205        .store
206        .read_history(handle.workflow_id())
207        .await?
208        .iter()
209        .any(|event| {
210            matches!(
211                event,
212                Event::WorkflowStarted {
213                    parent_run_id: Some(existing_parent),
214                    ..
215                } if existing_parent == &parent_run_id
216            )
217        });
218    if already_started {
219        return Ok(());
220    }
221
222    start::start_workflow_with_options(
223        StartWorkflowContext {
224            store: Arc::clone(&context.store),
225            visibility_store: Arc::clone(&context.visibility_store),
226            catalog: Arc::clone(&context.catalog),
227            runtime: Arc::clone(&context.runtime),
228            supervision: Arc::clone(&context.supervision),
229            registry: Arc::clone(&context.registry),
230            signal_handoff: None,
231            search_attribute_schema: Arc::clone(&context.search_attribute_schema),
232            monitor_tokio_handle: context.tokio_handle.clone(),
233        },
234        replacement_type,
235        input,
236        StartWorkflowOptions {
237            workflow_id: Some(handle.workflow_id().clone()),
238            // Continue-as-new reuses the existing id; steering does not apply.
239            routing_key: None,
240            parent_run_id: Some(parent_run_id),
241            // D1: the continue-as-new successor resolves the latest loaded
242            // version at record time, identically to the startup sweep.
243            loaded_version: None,
244            // Recorded attributes carry into the replacement run's projection.
245            search_attributes: std::collections::HashMap::new(),
246            namespace: Some(handle.namespace().to_owned()),
247        },
248    )
249    .await?;
250    Ok(())
251}
252
253pub(crate) fn terminal_outcome_from_history(
254    events: &[Event],
255    run_id: &RunId,
256) -> Option<TerminalOutcome> {
257    // Reset-aware: scope to the run, then take the current lease's terminal
258    // event. A WorkflowReopened after a terminal supersedes it, so a reopened run
259    // reports no terminal outcome until it terminates again.
260    match current_lease_terminal(run_segment(events, run_id))? {
261        Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
262        Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
263        Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
264        Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
265        Event::WorkflowContinuedAsNew {
266            input,
267            workflow_type,
268            parent_run_id,
269            ..
270        } if parent_run_id == run_id => Some(TerminalOutcome::ContinuedAsNew {
271            input: input.clone(),
272            workflow_type: workflow_type.clone(),
273            parent_run_id: parent_run_id.clone(),
274        }),
275        // current_lease_terminal yields only terminal lifecycle events; a
276        // ContinuedAsNew whose parent is a different run is not this run's
277        // outcome.
278        _ => None,
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use std::sync::Arc;
285
286    use aion_core::{Event, Payload, WorkflowStatus};
287    use aion_package::ContentHash;
288    use aion_store::visibility::VisibilityStore;
289    use aion_store::{EventStore, InMemoryStore};
290    use serde_json::json;
291
292    use super::{ProcessExitContext, handle_process_exit_async, terminal_outcome_from_history};
293    use crate::durability::Recorder;
294    use crate::loader::WorkflowCatalog;
295    use crate::registry::{
296        CompletionNotifier, HandleResidency, Registry, TerminalOutcome, WorkflowHandle,
297        WorkflowHandleParts,
298    };
299    use crate::runtime::{RuntimeConfig, RuntimeHandle, WorkflowProcessOutcome};
300    use crate::supervision::SupervisionTree;
301
302    struct ActiveWorkflow {
303        context: ProcessExitContext,
304        handle: WorkflowHandle,
305    }
306
307    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
308        Payload::from_json(&json!({ "label": label }))
309    }
310
311    fn workflow_error(message: &str) -> aion_core::WorkflowError {
312        aion_core::WorkflowError {
313            message: message.to_owned(),
314            details: None,
315        }
316    }
317
318    async fn active_workflow() -> Result<ActiveWorkflow, Box<dyn std::error::Error>> {
319        let backing = Arc::new(InMemoryStore::default());
320        let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
321        let visibility_store: Arc<dyn VisibilityStore> = backing;
322        let registry = Arc::new(Registry::default());
323        let workflow_id = aion_core::WorkflowId::new_v4();
324        let run_id = aion_core::RunId::new_v4();
325        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
326        recorder
327            .record_workflow_started(
328                chrono::Utc::now(),
329                crate::durability::WorkflowStartRecord {
330                    workflow_type: "checkout".to_owned(),
331                    input: payload("input")?,
332                    run_id: run_id.clone(),
333                    parent_run_id: None,
334                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
335                },
336            )
337            .await?;
338        let handle = WorkflowHandle::new(WorkflowHandleParts {
339            workflow_id: workflow_id.clone(),
340            run_id: run_id.clone(),
341            pid: 1,
342            workflow_type: "checkout".to_owned(),
343            namespace: String::from("default"),
344            loaded_version: ContentHash::from_bytes([9; 32]),
345            cached_status: WorkflowStatus::Running,
346            residency: HandleResidency::Resident,
347            recorder,
348            completion: CompletionNotifier::new(),
349        });
350        registry.insert((workflow_id, run_id), handle.clone())?;
351        Ok(ActiveWorkflow {
352            context: ProcessExitContext {
353                store,
354                visibility_store,
355                registry,
356                catalog: Arc::new(WorkflowCatalog::new()),
357                runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?),
358                supervision: Arc::new(SupervisionTree::new()),
359                tokio_handle: tokio::runtime::Handle::current(),
360                search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
361            },
362            handle,
363        })
364    }
365
366    #[tokio::test]
367    async fn normal_exit_records_completed_reconciles_and_notifies()
368    -> Result<(), Box<dyn std::error::Error>> {
369        let active = active_workflow().await?;
370        let result = payload("result")?;
371        let mut early = active.handle.completion().subscribe();
372
373        handle_process_exit_async(
374            active.context.clone(),
375            active.handle.clone(),
376            Ok(WorkflowProcessOutcome::Completed(result.clone())),
377        )
378        .await?;
379        early.changed().await?;
380
381        assert_eq!(
382            early.borrow().clone(),
383            Some(TerminalOutcome::Completed(result.clone()))
384        );
385        assert_eq!(
386            active.handle.completion().subscribe().borrow().clone(),
387            Some(TerminalOutcome::Completed(result.clone()))
388        );
389        let registered = active
390            .context
391            .registry
392            .get(active.handle.workflow_id(), active.handle.run_id())?
393            .ok_or("missing registered handle")?;
394        assert_eq!(registered.cached_status(), WorkflowStatus::Completed);
395        assert_eq!(registered.residency(), HandleResidency::Suspended);
396        let history = active
397            .context
398            .store
399            .read_history(active.handle.workflow_id())
400            .await?;
401        match history.as_slice() {
402            [
403                Event::WorkflowStarted { .. },
404                Event::WorkflowCompleted {
405                    result: recorded, ..
406                },
407            ] => {
408                assert_eq!(recorded, &result);
409            }
410            other => return Err(format!("expected started then completed, found {other:?}").into()),
411        }
412        Ok(())
413    }
414
415    #[test]
416    fn terminal_outcome_is_scoped_to_requested_run_segment()
417    -> Result<(), Box<dyn std::error::Error>> {
418        let old_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(1));
419        let new_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(2));
420        let input = payload("next")?;
421        let result = payload("done")?;
422        let workflow_id = aion_core::WorkflowId::new_v4();
423        let envelope = |seq| aion_core::EventEnvelope {
424            seq,
425            recorded_at: chrono::Utc::now(),
426            workflow_id: workflow_id.clone(),
427        };
428        let events = vec![
429            Event::WorkflowStarted {
430                envelope: envelope(1),
431                workflow_type: "checkout".to_owned(),
432                input: payload("first")?,
433                run_id: old_run_id.clone(),
434                parent_run_id: None,
435                package_version: aion_core::PackageVersion::new("a".repeat(64)),
436            },
437            Event::WorkflowContinuedAsNew {
438                envelope: envelope(2),
439                input: input.clone(),
440                workflow_type: None,
441                parent_run_id: old_run_id.clone(),
442            },
443            Event::WorkflowStarted {
444                envelope: envelope(3),
445                workflow_type: "checkout".to_owned(),
446                input,
447                run_id: new_run_id.clone(),
448                parent_run_id: Some(old_run_id.clone()),
449                package_version: aion_core::PackageVersion::new("a".repeat(64)),
450            },
451            Event::WorkflowCompleted {
452                envelope: envelope(4),
453                result: result.clone(),
454            },
455        ];
456
457        assert_eq!(
458            terminal_outcome_from_history(&events, &old_run_id),
459            Some(TerminalOutcome::ContinuedAsNew {
460                input: payload("next")?,
461                workflow_type: None,
462                parent_run_id: old_run_id,
463            })
464        );
465        assert_eq!(
466            terminal_outcome_from_history(&events, &new_run_id),
467            Some(TerminalOutcome::Completed(result))
468        );
469        Ok(())
470    }
471
472    #[tokio::test]
473    async fn process_exit_resumes_interrupted_deadline_cancellation()
474    -> Result<(), Box<dyn std::error::Error>> {
475        // An interrupted terminal transition: the terminal committed but its
476        // deadline cancellation did not, leaving the deadline outstanding. The
477        // process-exit monitor, re-encountering the run's own terminal, completes
478        // the interrupted cancellation under the recorder lock — closing the
479        // whole-history re-arm hazard.
480        let active = active_workflow().await?;
481        let run_id = active.handle.run_id().clone();
482        let deadline_id = crate::time::deadline_timer_id(&run_id)?;
483        let result = payload("result")?;
484        {
485            let recorder = active.handle.recorder();
486            let mut recorder = recorder.lock().await;
487            recorder
488                .record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
489                .await?;
490            recorder
491                .record_workflow_completed(chrono::Utc::now(), result.clone())
492                .await?;
493        }
494        let before = active
495            .context
496            .store
497            .read_history(active.handle.workflow_id())
498            .await?;
499        assert!(
500            crate::time::outstanding_deadline_timer(&before, &run_id).is_some(),
501            "the deadline is outstanding before the resume"
502        );
503
504        handle_process_exit_async(
505            active.context.clone(),
506            active.handle.clone(),
507            Ok(WorkflowProcessOutcome::Completed(result.clone())),
508        )
509        .await?;
510
511        let history = active
512            .context
513            .store
514            .read_history(active.handle.workflow_id())
515            .await?;
516        assert_eq!(
517            crate::time::outstanding_deadline_timer(&history, &run_id),
518            None,
519            "re-encountering the own terminal completes the deadline cancellation: {history:#?}"
520        );
521        Ok(())
522    }
523
524    #[tokio::test]
525    async fn process_exit_does_not_retire_a_timed_out_deadline()
526    -> Result<(), Box<dyn std::error::Error>> {
527        // Item 3×5 interaction: timeout teardown keeps its deadline live as its
528        // resume anchor across fallible visibility work, and the process-exit
529        // monitor it wakes via `cancel_pid` must NOT retire that TimedOut
530        // deadline. A monitor that retired it would strand an interrupted
531        // teardown.
532        let active = active_workflow().await?;
533        let run_id = active.handle.run_id().clone();
534        let deadline_id = crate::time::deadline_timer_id(&run_id)?;
535        {
536            let recorder = active.handle.recorder();
537            let mut recorder = recorder.lock().await;
538            recorder
539                .record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
540                .await?;
541            recorder
542                .record_workflow_timed_out(chrono::Utc::now(), String::from("workflow"))
543                .await?;
544        }
545
546        handle_process_exit_async(
547            active.context.clone(),
548            active.handle.clone(),
549            Ok(WorkflowProcessOutcome::Completed(payload("late")?)),
550        )
551        .await?;
552
553        let history = active
554            .context
555            .store
556            .read_history(active.handle.workflow_id())
557            .await?;
558        assert!(
559            crate::time::outstanding_deadline_timer(&history, &run_id).is_some(),
560            "the monitor must leave a TimedOut deadline live for its owning teardown: {history:#?}"
561        );
562        Ok(())
563    }
564
565    #[tokio::test]
566    async fn abnormal_exit_records_failed_reconciles_and_notifies()
567    -> Result<(), Box<dyn std::error::Error>> {
568        let active = active_workflow().await?;
569        let error = workflow_error("process crashed: error");
570        let mut early = active.handle.completion().subscribe();
571
572        handle_process_exit_async(
573            active.context.clone(),
574            active.handle.clone(),
575            Ok(WorkflowProcessOutcome::Failed(error.clone())),
576        )
577        .await?;
578        early.changed().await?;
579
580        assert_eq!(
581            early.borrow().clone(),
582            Some(TerminalOutcome::Failed(error.clone()))
583        );
584        assert_eq!(
585            active.handle.completion().subscribe().borrow().clone(),
586            Some(TerminalOutcome::Failed(error.clone()))
587        );
588        let registered = active
589            .context
590            .registry
591            .get(active.handle.workflow_id(), active.handle.run_id())?
592            .ok_or("missing registered handle")?;
593        assert_eq!(registered.cached_status(), WorkflowStatus::Failed);
594        assert_eq!(registered.residency(), HandleResidency::Suspended);
595        let history = active
596            .context
597            .store
598            .read_history(active.handle.workflow_id())
599            .await?;
600        match history.as_slice() {
601            [
602                Event::WorkflowStarted { .. },
603                Event::WorkflowFailed {
604                    error: recorded, ..
605                },
606            ] => {
607                assert_eq!(recorded, &error);
608            }
609            other => return Err(format!("expected started then failed, found {other:?}").into()),
610        }
611        Ok(())
612    }
613}